Merge pull request 'feat(payment): separate creator income settlement' (#404) from worktree-creator-income-rework into main

Reviewed-on: CYL/wordpress-theme-aksrt#404
This commit is contained in:
2026-08-07 12:43:27 +08:00
17 changed files with 451 additions and 445 deletions
+20 -21
View File
@@ -7,7 +7,7 @@
- **作者收益与推广返佣分离**:创作中心只读取付费文章作者收益,不读取 `Rebate` 推广返佣数据。
- **收益记录以订单为源**:支付成功且权益发放完成后,每笔符合条件的付费订单最多生成一条作者收益记录。
- **收益生成时快照**:保存原始支付金额、货币、作者分成比例和适用会员等级,后续配置或等级变化不影响历史记录。
- **现金与积分彻底分离**:分别记录、统计、结算、写入流水和执行冲正;不换算、不合并。
- **现金与积分彻底分离**:分别记录、统计、结算和写入流水;不换算、不合并,退款不改变作者收益。
- **统一认证边界**:前台作者 ID 始终取当前登录用户,后台接口复用支付中心权限模型;所有 REST 路由必须提供 `permission_callback`。
- **原子余额操作**:现金和积分余额只能通过现有余额服务变更,不直接修改用户 meta。
- **最终结构替换**:新增或调整表结构时直接更新最终 schema 版本,不增加旧字段双读、迁移层或兼容路径。
@@ -37,22 +37,21 @@
---
## Phase 2 — 收益自动/手动结算与退款冲正
## 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` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。
- [x] `settlement scheduler` 按站点时区处理达到配置结算周期的待结算记录;结算周期结束后自动入对应钱包,不再配置延迟小时或自动/手动模式。
- [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [x] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。
- [x] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。
- [ ] 重复退款通知和重复冲正请求保持幂等;服务测试覆盖自动结算、手动结算、退款前结算、退款后结算和余额不足场景。
- [x] 订单退款或取消不会取消 pending 收益、冲正 settled 收益或产生待追缴记录;收益只按正常周期进入对应钱包。
- [ ] 服务测试覆盖现金/积分比例快照、周期边界、自动结算、手动重试、退款后收益保持和并发幂等场景。
---
@@ -62,7 +61,7 @@
### What to build
将用户中心“创作中心”从文章管理页面改造成作者收益页面。实现现金/积分双货币收益卡片、待结算与已入钱包展示、创作分成说明 Tab、收入明细 Tab,以及基于现有用户中心 REST/Ajax 机制的无刷新筛选和分页。
将用户中心“创作中心”改造成作者收益页面。实现现金/积分双货币收益卡片、待结算与已结算展示、结算备注和三列收入明细,并保留基于现有用户中心 REST/Ajax 机制的分页加载。
保留独立的“收益中心”推广返佣入口,确保两个页面的数据和开关互不影响。
@@ -70,11 +69,11 @@
- [ ] `src/User/Center.php`、`inc/helpers/user-center-renderers/sidebar-commerce.php` 和相关侧栏逻辑在创作分成关闭时隐藏创作中心入口,但仍显示独立的收益中心入口。
- [ ] 用户中心创作中心渲染器不再输出文章发布、草稿、审核、编辑、删除或文章管理内容。
- [ ] `Creator income view model and renderers` 实现现金/积分今日收入、累计收益、待结算和已入钱包数据,并排除取消、退款和待追缴金额。
- [ ] `Creator income view model and renderers` 实现现金/积分今日收入、累计收益、待结算和已结算数据,列表只输出“现金收益/积分收益、+金额、待结算/已结算”。
- [ ] `GET /wp-json/aksrt/v1/user-center/...` 作者收益接口只使用当前认证用户,未登录或访问他人数据时被拒绝;路由均具备 `permission_callback`。
- [ ] 创作分成 Tab 输出经过 `wp_kses` 处理的后台 HTML;收入明细默认每页 20 条、按产生时间倒序,并包含文章、订单、支付金额、作者分成金额、货币和收益状态。
- [ ] `assets/js/routes/user-center.js` 完成两个 Tab、筛选、分页和 History API 无刷新加载;请求失败时保留卡片和布局并显示可重试提示。
- [ ] 前台交互测试验证现金/积分分栏、空状态、筛选组合、退款/待追缴展示、移动端无横向溢出,以及创作中心与收益中心数据隔离。
- [ ] 结算备注输出经过 `wp_kses` 处理的后台 HTML;收入明细默认每页 20 条、按产生时间倒序,移除货币 Tab 和状态筛选。
- [ ] `assets/js/routes/user-center.js` 只转发创作中心分页参数并保持 History API 无刷新加载;请求失败时保留卡片和布局并显示可重试提示。
- [ ] 前台交互测试验证现金/积分分栏、空状态、分页、退款后收益保持、移动端无横向溢出,以及创作中心与收益中心数据隔离。
---
@@ -84,16 +83,16 @@
### What to build
在“支付&付费 > 创作中心”集中提供创作开关、三档独立分成比例、处理模式、延迟时间、现金提示阈值和说明 HTML 配置。复用现有支付中心“创作分成”Native 运营页面,补齐作者收益查询、筛选、逐笔/批量结算、退款冲正和待追缴查看。
在“支付&付费 > 创作中心”集中提供开关、现金/积分两套三档分成比例、结算周期和结算备注文案 HTML。复用现有支付中心“创作分成”Native 运营页面,提供作者收益查询、筛选、逐笔/批量结算重试。
### Done when
- [ ] `src/Admin/Schema/SchemaPayment.php`、`assets/admin/src/schema/sections/payment/index.js` 和支付菜单文件共同注册“创作中心”配置项。
- [ ] 配置 REST 能加载和保存开关、60/70/80 比例、自动/手动模式、延迟小时数、50 元提示阈值和 HTML 说明;非法比例、负数延迟和不允许的模式值被拒绝。
- [ ] 配置 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` 通过,后台配置与运营测试覆盖保存、权限、查询和操作结果。
---
@@ -104,13 +103,13 @@
### What to build
对收益生成、结算、退款冲正、用户中心、后台配置和运营页面进行跨模块联调,完成静态检查、前端构建、契约审计和可用的 WordPress/外部 Docker harness 验证。修复跨阶段集成问题,并明确记录环境不可用时未执行的运行时验证。
对收益生成、周期结算、退款保持、用户中心、后台配置和运营页面进行跨模块联调,完成静态检查、前端构建、契约审计和可用的 WordPress/外部 Docker harness 验证。修复跨阶段集成问题,并明确记录环境不可用时未执行的运行时验证。
### Done when
- [ ] 收益服务测试覆盖比例快照、货币分离、订单幂等、到期结算、退款冲正、待追缴和并发重复操作。
- [ ] 前台交互测试覆盖开关门控、本人数据隔离、双货币卡片、Tab 无刷新、分页筛选、错误重试、移动端布局和推广返佣隔离。
- [ ] 后台配置与运营测试覆盖 Schema/REST 契约、权限、自动/手动模式、批量结算、退款记录和待追缴查询。
- [ ] 收益服务测试覆盖现金/积分比例快照、货币分离、订单幂等、周期到期结算、退款后收益保持和并发重复操作。
- [ ] 前台交互测试覆盖开关门控、本人数据隔离、双货币卡片、分页、错误重试、移动端布局和推广返佣隔离。
- [ ] 后台配置与运营测试覆盖 Schema/REST 契约、权限、周期自动结算、批量结算重试和现金/积分查询。
- [ ] `php tools/audit-admin-contracts.php` 返回成功状态。
- [ ] `assets/admin` 执行 `npm run build` 成功,生成的 `assets/admin/build/` 文件保持可加载。
- [ ] 按环境条件完成 PHP 静态检查和 WordPress/外部 Docker harness 运行验证;缺少运行环境时,在交付记录中明确列出未执行项及原因。
@@ -127,11 +126,11 @@
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不允许余额因冲正而变为负数,也不实现自动追缴扣款之外的催收流程。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
## Open Questions
暂无。PRD 中的收益口径、结算策略、货币处理、权限、退款冲正和后台入口均已确定。
暂无。PRD 中的收益口径、结算策略、货币处理、权限、退款保持和后台入口均已确定。
+51 -105
View File
@@ -4,7 +4,7 @@
当前用户中心的“创作中心”主要承担文章发布与管理,尚未提供付费文章作者收益的统一查看和结算状态反馈;现有“收益中心”又主要面向推广返佣,容易让用户混淆两类收益来源。
本需求将“创作中心”改造成只面向付费文章作者收益的页面,展示作者从付费内容订单中获得的现金和积分分成,并提供创作分成说明与收入明细。文章发布、草稿、审核、编辑和删除等管理功能本次不再显示于用户中心。
本需求将“创作中心”改造成只面向付费文章作者收益的页面,展示作者从付费内容订单中获得的现金和积分分成,并提供结算备注与收入明细。文章发布、草稿、审核、编辑和删除等管理功能本次不再显示于用户中心。
创作分成功能需要独立开关。关闭后,用户中心隐藏创作中心入口,停止新作者收益生成、自动结算和手动结算;已有收益记录仍保留在后台供查询,不新增兼容读取或历史迁移逻辑。
@@ -12,7 +12,7 @@
系统已有用户中心面板注册与 REST 延迟加载机制,可用于实现无刷新页面切换;已有付费订单、积分余额、现金余额、作者收益台账、结算服务及支付后台配置体系。
现有创作中心页面包含文章管理功能,现有收益中心主要展示推广返佣。现有作者收益能力尚未完整接入付费文章作者视图,也未提供现金与积分分开统计、按收益记录分页筛选及退款冲正状态展示。
现有创作中心页面包含文章管理功能,现有收益中心主要展示推广返佣。现有作者收益能力尚未完整接入付费文章作者视图,也未提供现金与积分分开统计、按收益记录分页展示及退款后收益保持。
## Solution
@@ -30,9 +30,9 @@
#### 今日收入
- 统计当天产生的有效作者分成记录,包括待结算和已入钱包的收益。
- 统计当天产生的有效作者分成记录,包括待结算和已结算的收益。
- 现金收益与积分收益分栏展示,不进行汇率换算或合并。
- 退款、取消及已冲正的记录不计入有效收入。
- 退款或取消订单不改变作者收益记录;统计只包含 pending 和 settled 状态。
- 无当日收益时显示零值和合理的空状态,不隐藏卡片。
#### 我的收入
@@ -44,47 +44,13 @@
- 积分收益不受 50 元提现提示影响。
- 卡片不混入推广返佣数据。
### 主内容 Tab
### 收入明细
页面主体包含两个 Tab,不设置 Tab 侧边滚动栏:
页面不再输出顶部 hero 或货币 Tab,现金收益和积分收益统计卡片直接置顶。结算备注和收入明细在同一页面展示,分页使用 Ajax/REST 局部加载,不刷新整个用户中心页面。
1. 创作分成
2. 收入明细
后台可配置安全 HTML 格式的结算备注文案,前台经过 `wp_kses_post` 清洗后输出。收入明细按单笔作者收益记录、产生时间倒序排列,每页 20 条;每行固定为三列:左侧“现金收益/积分收益”,中间“+XX现金/+XX积分”,右侧“待结算/已结算”。不再提供货币 Tab 或状态筛选。
Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不刷新整个用户中心页面;切换后保留当前用户中心布局,并同步更新浏览器历史状态。
#### 创作分成 Tab
允许后台配置 HTML 格式的说明内容。默认内容为:
> 在本站发布付费内容,获得的收益将参与收入分成<br>
> 发布付费内容,请注意以下事项<br>
> 普通用户分成比例为:60%<br>
> 黄金会员分成比例为:70%<br>
> 钻石会员分成比例为:80%<br>
> 分成比例的计算方式为:付费文章实际收益x分成比例<br>
> 获得收入超过50元后可申请现金提现<br>
> 申请提现后,需后台人工处理,一般2-3小时,请耐心等待<br>
实际显示的比例和会员名称应根据后台当前配置渲染;收益记录使用生成时锁定的比例,不因后续配置修改而改变。
#### 收入明细 Tab
按单笔作者收益记录展示付费文章收益,默认按产生时间倒序排列,每页 20 条。每条记录至少包含:
- 付费文章标题,并链接至文章详情。
- 实际支付金额及货币类型(现金或积分)。
- 作者分成金额及对应货币类型。
- 订单号、购买时间和订单状态。
- 作者收益状态:待结算、已入钱包、已取消/退款或待追缴。
支持以下筛选,并通过 Ajax 无刷新更新列表:
- 货币类型:全部、现金、积分。
- 收益状态:全部、待结算、已入钱包、已取消/退款、待追缴。
- 日期范围。
无记录时显示统一空状态;请求失败时显示可重试的错误提示,不破坏已加载的卡片和 Tab 布局。
无记录时显示统一空状态;请求失败时显示可重试的错误提示,不破坏已加载的卡片和列表布局。
### 作者收益规则
@@ -95,43 +61,37 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
- 普通用户默认 60%,一级会员默认 70%,二级会员默认 80%。这些比例在创作中心中独立配置,不跟随其他会员或商品分成设置变化。
- 生成收益记录时锁定分成比例及适用等级;历史收益不因后台比例或用户等级后续变化而重新计算。
- 现金订单只生成现金作者收益,积分订单只生成积分作者收益。
- 两种货币共用普通用户、一级会员、二级会员三档比例,但分别统计、分别建立台账、分别结算。
- 现金与积分分别配置普通用户、一级会员、二级会员三档比例,分别统计、分别建立台账、分别结算。
- 现金作者收益结算至现金余额,积分作者收益结算至积分余额。
- 50 元仅作为现金提现提示,不作为作者收益进入钱包的结算门槛;积分收益不受该提示影响。
### 结算策略
- 默认收益产生后延迟 24 小时进入可处理状态。
- 后台可选择自动处理或手动处理。
- 自动处理模式由定时任务处理已达到延迟小时数的待结算收益;默认延迟时间为 24 小时,后台可配置延迟小时数。
- 手动处理模式下,管理员在现有支付中心“创作分成”运营页面中查询、筛选并逐笔或批量结算收益。
- 自动和手动处理均必须保证每条收益只能成功结算一次,现金和积分分别执行原子入账。
- 结算成功后更新收益状态并写入对应余额流水;失败时保留待结算状态和错误信息供后台重试。
- 后台只配置结算周期(默认 30 天),不再配置最小结算金额、延迟小时或自动/手动模式。
- 定时任务按 WordPress 站点时区扫描 created_at 已早于当前时间减去结算周期的 pending 记录,周期结束后自动将现金/积分分别写入对应钱包。
- 管理员仍可在现有支付中心“创作分成”运营页面逐笔或批量手动重试结算,但这不是用户可配置的处理模式。
- 每条收益只能成功入账一次;失败时保留 pending 状态供后续扫描或后台重试。
### 退款与冲正
### 退款处理
- 退款或取消订单后,对应作者收益标记为已取消/退款,不再计入有效收入。
- 尚未入钱包的作者收益直接取消,不得继续结算。
- 已入钱包的作者收益执行对应货币的原子冲正:现金从现金余额扣回,积分从积分余额扣回。
- 余额不足时不得静默扣成负数;收益记录进入待追缴状态,并保留后台处理入口、原始收益、应冲正金额和处理审计信息。
- 冲正和结算必须具备幂等性,重复退款通知或重复后台操作不能重复扣款或重复改变状态。
- 退款或取消订单不会取消 pending 收益、冲正 settled 收益或产生追缴记录。
- 收益仍按正常结算周期进入对应钱包,现金和积分余额操作始终通过各自原子服务完成。
## User Stories
1. 作为登录用户,我可以从用户中心进入创作中心,查看付费文章产生的作者收益。
2. 作为作者,我可以在顶部同时看到今日现金收益、今日积分收益及其累计状态,而不会将两种货币混为一个数字。
3. 作为作者,我可以查看创作分成规则,并看到后台配置的最新说明内容。
4. 作为作者,我可以按订单级别查看每笔付费文章收益,确认文章、订单、支付金额、货币和收益状态。
5. 作为作者,我可以按货币、收益状态和日期筛选收入明细,并通过分页加载更多记录而不刷新页面。
4. 作为作者,我可以按订单级别查看每笔付费文章收益,并看到现金/积分金额和结算状态。
5. 作为作者,我可以通过分页查看混合现金/积分收入明细而不刷新页面。
6. 作为无收益用户,我可以看到清晰的零值和空状态,而不是空白页面或错误。
7. 作为作者,我可以看到待结算收益与已入钱包收益的区别,并知道现金提现提示不等同于作者收益结算门槛。
7. 作为作者,我可以看到待结算收益与已结算收益的区别。
8. 作为管理员,我可以独立开启或关闭创作分成功能;关闭后前台入口和收益处理流程均停止。
9. 作为管理员,我可以配置普通用户、一级会员和二级会员的作者分成比例,默认值分别为 60%、70% 和 80%。
10. 作为管理员,我可以配置自动或手动处理收益,并配置默认 24 小时的延迟处理时间。
11. 作为管理员,我可以在现有支付中心创作分成运营页面查看作者、文章、货币、状态和时间,并手动逐笔或批量结算。
12. 作为管理员,我可以查看退款、取消和待追缴记录,并区分已入钱包后的冲正情况。
13. 作为作者,订单退款后可以看到对应收益被取消或冲正,而不会继续获得已失效收入。
14. 作为作者,即使余额不足以完成退款冲正,系统也不会产生负余额,而会将记录标记为待追缴。
9. 作为管理员,我可以分别配置现金和积分的普通用户、一级会员、二级会员分成比例。
10. 作为管理员,我可以配置结算周期,周期结束后收益自动进入对应钱包。
11. 作为管理员,我可以在现有支付中心创作分成运营页面查看作者、文章、货币、状态和时间,并手动逐笔或批量重试结算。
12. 作为管理员,我可以按现金/积分、作者、文章和状态查询收益记录。
13. 作为作者,订单退款后收益不会被取消、冲正或追缴。
15. 作为用户,创作中心与推广返佣收益中心仍然是两个独立入口,页面数据不会相互混淆。
16. 作为未登录访客,我不能访问创作中心收益数据或其他用户的收入明细。
@@ -141,20 +101,19 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
#### Creator income service
负责作者收益的统一业务入口,隐藏订单筛选、比例快照、金额计算、状态转换、结算和冲正细节。
负责作者收益的统一业务入口,隐藏订单筛选、比例快照、金额计算、状态转换和结算细节。
主要接口:
- `create_from_paid_order(order)`:支付成功且权益完成后创建待结算作者收益。
- `get_user_summary(user_id, date)`:返回今日与累计的现金/积分有效收益、待结算和已入钱包金额。
- `get_user_summary(user_id, date)`:返回今日与累计的现金/积分有效收益、待结算和已结算金额。
- `get_user_records(user_id, filters, page, per_page)`:返回作者本人分页明细及总数。
- `settle(record_ids, actor)`:按货币原子结算指定收益记录。
- `cancel_or_reverse(order_id, reason)`:处理未结算取消或已结算冲正。
- `get_admin_records(filters, page, per_page)`:为现有支付中心运营页面提供管理员查询数据。
#### Creator income view model and renderers
负责将收益服务数据转换为用户中心卡片、Tab 内容、明细行、分页、筛选项和空/错状态所需的前台结构。创作分成说明内容必须经过允许 HTML 的清洗后输出。
负责将收益服务数据转换为用户中心双货币卡片、结算备注、三列明细行、分页和空/错状态所需的前台结构。结算备注必须经过允许 HTML 的清洗后输出。
#### Creator income REST endpoints
@@ -162,11 +121,7 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
#### Settlement scheduler
负责扫描已达到延迟小时数的待结算收益,在自动模式下触发现金/积分分别结算。任务必须可重复执行且具备锁定、幂等和失败重试行为。
#### Refund reversal handler
负责监听付费订单退款或取消事件,取消未结算收益,或调用现金/积分余额服务完成冲正;余额不足时写入待追缴状态。
负责按站点时区扫描已达到结算周期的待结算收益,触发现金/积分分别结算。任务必须可重复执行且具备幂等和失败重试行为;退款或取消事件不挂接作者收益处理。
### Architectural Decisions
@@ -185,13 +140,9 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
在“支付&付费 > 创作中心”下集中配置:
- 创作分成独立开关,默认关闭或沿用产品确定的启用初始状态。
- 普通用户作者分成比例,默认 60%。
- 一级会员作者分成比例,默认 70%。
- 二级会员作者分成比例,默认 80%。
- 收益处理模式:自动或手动。
- 自动处理延迟小时数,默认 24 小时。
- 现金提现提示阈值,默认 50,仅用于前台说明文案,不参与钱包结算门槛。
- 创作分成 Tab 的 HTML 说明内容,默认填充需求中的说明文案。
- 现金收益和积分收益各自配置普通用户、一级会员、二级会员比例,默认现金为 60%/70%/80%,积分默认 0%/0%/0%(需显式开启积分分成)。
- 结算周期,默认 30 天;周期结束后自动进入对应钱包。
- 结算备注文案,支持安全 HTML;不再配置最小结算金额、延迟小时、处理模式或展示设置。
作者收益记录需要能够保存:
@@ -199,9 +150,8 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
- 原始实际支付金额及货币。
- 生成时锁定的分成比例和会员等级。
- 作者分成金额及结算货币。
- 待结算、已入钱包、已取消/退款、待追缴等状态。
- 生成、到期、结算、取消、冲正时间。
- 冲正金额、错误信息和后台处理审计信息。
- 待结算、已结算两种新状态。
- 生成、到期和结算时间;历史 reversal 字段不再被新业务读取。
新增或调整存储结构时直接采用最终结构并更新数据库 schema 版本;不对旧结构增加兼容字段、双读路径或数据迁移层。现有历史收益只作为后台已有记录处理,不纳入新作者收益统计口径。
@@ -209,9 +159,9 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
用户端接口应提供:
- 作者收益摘要:现金/积分今日收益、累计收益、待结算和已入钱包金额。
- 创作分成说明:经过安全 HTML 处理的配置内容及当前比例展示数据。
- 收入明细:支持货币、状态、日期范围、页码和每页数量;返回记录列表、总数、当前页和分页信息。
- 作者收益摘要:现金/积分今日收益、累计收益、待结算和已结算金额。
- 结算备注:经过安全 HTML 处理的配置内容。
- 收入明细:支持页码和每页数量;返回混合现金/积分记录、总数、当前页和分页信息。
收入记录响应至少包含:
@@ -219,7 +169,7 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
- 订单号、订单状态、支付时间。
- 原始支付金额与货币。
- 作者分成金额与货币。
- 收益状态及必要的取消、退款或待追缴说明。
- 收益状态:待结算或已结算。
所有用户接口必须验证登录状态、规范化分页参数、限制日期范围和筛选值,并将作者 ID 从当前认证用户上下文取得,不接受客户端传入的任意作者 ID。
@@ -243,35 +193,31 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
- 现金订单只进入现金收益,积分订单只进入积分收益。
- 订单支付成功且权益完成时生成收益;重复通知不重复生成。
- 非付费文章、其他订单类型、作者购买自己的文章不会生成作者收益。
- 今日统计按站点时间计算,累计统计排除取消、退款和待追缴的无效金额。
- 默认 24 小时前不可自动结算,达到延迟时间后可结算。
- 自动和手动结算均不能重复入账;现金和积分余额流水分别正确写入。
- 退款前结算和退款后结算两种路径都能正确取消或冲正。
- 余额不足时不产生负余额,收益进入待追缴并保留审计信息。
- 重复退款通知、重复结算请求和并发任务均保持幂等。
- 今日统计按站点时间计算,累计统计只包含 pending/settled,现金与积分不混加。
- 收益达到配置结算周期后自动结算;管理员手动重试和定时任务均不能重复入账。
- 退款或取消订单不改变收益状态、不扣回余额、不产生追缴。
- 重复结算请求和并发任务均保持幂等。
### 前台交互测试
- 创作分成开关关闭时,侧栏入口隐藏、页面不可访问、新收益不生成且不执行结算。
- 登录用户只能看到自己的摘要和明细;未登录访问被拒绝或按用户中心既有规则处理。
- 顶部两张卡片正确分栏显示现金与积分,并显示待结算和已入钱包金额。
- 创作分成说明支持安全 HTML 输出,并正确展示默认内容和后台修改内容。
- 两个 Tab Ajax 切换无整页刷新,不出现 Tab 侧边滚动栏。
- 收入明细默认 20 条、时间倒序,翻页无刷新并保持筛选条件。
- 货币、状态、日期筛选组合正确,空结果显示空状态,请求失败显示可重试提示。
- 文章链接、订单信息、支付金额、作者分成金额和收益状态显示正确。
- 退款、取消和待追缴记录在明细中可见且不计入有效收入卡片。
- 顶部两张卡片直接显示现金与积分,并显示待结算和已结算金额。
- 结算备注支持安全 HTML 输出,并正确展示默认内容和后台修改内容。
- 页面不出现 hero 或货币/状态 Tab;收入明细默认 20 条、时间倒序,翻页无刷新。
- 明细三列正确显示收益货币、带单位的正数金额和待结算/已结算状态。
- 退款后记录仍保持原状态,不出现冲正或追缴文案。
- 创作中心与推广返佣收益中心数据和页面入口保持独立。
- 移动端沿用用户中心现有抽屉/Tab 行为,卡片和明细不发生横向溢出。
### 后台配置与运营测试
- 创作中心配置页面正确加载默认值、保存合法值并拒绝无效比例和负数延迟时间。
- 比例、开关、处理模式、延迟小时数、提现提示阈值和 HTML 说明可独立保存。
- 自动模式按延迟小时数处理到期收益,手动模式不会被定时任务自动结算。
- 创作中心配置页面正确加载默认值、保存合法值并拒绝无效比例和周期值。
- 现金/积分两套比例、开关、结算周期和 HTML 备注可独立保存。
- 定时任务按站点时区和结算周期处理到期收益,不再存在自动/手动模式或延迟小时配置。
- 手动结算支持逐笔和批量操作,并正确反馈成功、失败和已处理记录。
- 后台列表支持作者、文章、货币、收益状态、订单状态和时间查询。
- 管理员可查看退款冲正和待追缴记录,普通用户不能访问运营接口。
- 管理员可按现金/积分、作者、文章、状态和时间查询记录,普通用户不能访问运营接口;退款不提供冲正或追缴入口。
- 配置和路由契约审计通过:`php tools/audit-admin-contracts.php`。
- 按项目现有条件完成 PHP 静态检查、前端构建和人工/运行时验证;若测试环境不具备 WordPress 或外部 Docker harness,应明确记录未执行项,不以静态通过替代运行验证。
@@ -285,7 +231,7 @@ Tab 切换、收入明细分页和筛选均使用 Ajax/REST 局部加载,不
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不允许余额因冲正而变为负数,也不实现自动追缴扣款之外的催收流程。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
@@ -2,7 +2,7 @@
<PaymentSectionPage
section="revenue"
title="收益中心"
desc="查看收入流水、收益统计和提现记录。"
desc="查看现金与积分收入流水、收益统计和提现记录。"
:links="[
{ label: 'VIP 会员', path: '/payment/vip' },
{ label: '推广返佣', path: '/payment/affiliate' },
@@ -11,28 +11,10 @@
<template #before-form>
<n-card class="aksrt-section-card" title="收益统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-grid-item v-for="item in revenueSummary" :key="item.key">
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.total.toFixed(2) }}</strong>
<p>总收益 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.pending.toFixed(2) }}</strong>
<p>待结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.settled.toFixed(2) }}</strong>
<p>已结算 (元)</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ revenueStats.count }}</strong>
<p>收益笔数</p>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</n-grid-item>
</n-grid>
@@ -47,24 +29,28 @@
</n-space>
</template>
<div v-if="timeSeries.length > 0" class="aksrt-revenue__chart">
<div class="aksrt-revenue__chart-bars">
<div
v-for="(item, index) in timeSeries"
:key="index"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
>
<div v-if="hasTimeSeries" class="aksrt-revenue__charts">
<div v-for="group in timeSeriesGroups" :key="group.key" class="aksrt-revenue__chart">
<h4>{{ group.label }}</h4>
<div v-if="group.items.length > 0" class="aksrt-revenue__chart-bars">
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
v-for="(item, index) in group.items"
:key="`${group.key}-${index}`"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ${formatAmount(item.amount, group.key)} (${item.count}笔)`"
>
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount, group.items) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
</div>
</div>
<n-empty v-else size="small" description="暂无数据" />
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
<span>共 {{ group.count }} 笔</span>
</div>
</div>
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>¥{{ periodTotal.toFixed(2) }}</strong></span>
<span>共 {{ periodCount }} 笔</span>
</div>
</div>
<n-empty v-else description="暂无趋势数据" />
@@ -119,23 +105,53 @@ import { ref, computed, onMounted } from 'vue'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const revenueStats = ref({ total: 0, pending: 0, settled: 0, count: 0 })
const emptyCurrencyStats = () => ({ total: 0, pending: 0, settled: 0, count: 0 })
const revenueStats = ref({ cash: emptyCurrencyStats(), points: emptyCurrencyStats() })
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: 0 })
const timeSeries = ref([])
const timeSeries = ref({ cash: [], points: [] })
const granularity = ref('day')
const periodTotal = computed(() => timeSeries.value.reduce((s, i) => s + i.amount, 0))
const periodCount = computed(() => timeSeries.value.reduce((s, i) => s + i.count, 0))
const maxAmount = computed(() => Math.max(1, ...timeSeries.value.map(i => i.amount)))
const revenueSummary = computed(() => [
{ key: 'cash-total', value: `${revenueStats.value.cash.total.toFixed(2)} 元`, label: '现金总收益' },
{ key: 'cash-pending', value: `${revenueStats.value.cash.pending.toFixed(2)} 元`, label: '现金待结算' },
{ key: 'cash-settled', value: `${revenueStats.value.cash.settled.toFixed(2)} 元`, label: '现金已结算' },
{ key: 'cash-count', value: revenueStats.value.cash.count, label: '现金收益笔数' },
{ key: 'points-total', value: `${formatAmount(revenueStats.value.points.total, 'points')}`, label: '积分总收益' },
{ key: 'points-pending', value: `${formatAmount(revenueStats.value.points.pending, 'points')}`, label: '积分待结算' },
{ key: 'points-settled', value: `${formatAmount(revenueStats.value.points.settled, 'points')}`, label: '积分已结算' },
{ key: 'points-count', value: revenueStats.value.points.count, label: '积分收益笔数' },
])
function getBarHeight(amount) {
return Math.max(2, (amount / maxAmount.value) * 100)
const timeSeriesGroups = computed(() => [
{
key: 'cash',
label: '现金收益',
items: Array.isArray(timeSeries.value.cash) ? timeSeries.value.cash : [],
},
{
key: 'points',
label: '积分收益',
items: Array.isArray(timeSeries.value.points) ? timeSeries.value.points : [],
},
].map((group) => ({
...group,
total: group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0),
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
})))
const hasTimeSeries = computed(() => timeSeriesGroups.value.some((group) => group.items.length > 0))
function formatAmount(amount, currency) {
return currency === 'points' ? `${Number(amount || 0).toFixed(0)} 积分` : `¥${Number(amount || 0).toFixed(2)}`
}
function getBarHeight(amount, items) {
const maxAmount = Math.max(1, ...items.map((item) => Number(item.amount || 0)))
return Math.max(2, (Number(amount || 0) / maxAmount) * 100)
}
function formatLabel(date) {
if (granularity.value === 'month') return date.slice(5)
if (granularity.value === 'week') return date.slice(5)
return date.slice(5)
return String(date || '').slice(5)
}
function setGranularity(nextGranularity) {
@@ -153,10 +169,8 @@ async function loadRevenue() {
if (res?.data) {
if (res.data.revenue) {
revenueStats.value = {
total: Number(res.data.revenue.total || 0),
pending: Number(res.data.revenue.pending || 0),
settled: Number(res.data.revenue.settled || 0),
count: Number(res.data.revenue.count || 0),
cash: { ...emptyCurrencyStats(), ...(res.data.revenue.cash || {}) },
points: { ...emptyCurrencyStats(), ...(res.data.revenue.points || {}) },
}
}
if (res.data.withdrawal) {
@@ -169,7 +183,10 @@ async function loadRevenue() {
total_paid: Number(res.data.withdrawal.total_paid || 0),
}
}
timeSeries.value = res.data.time_series || []
timeSeries.value = {
cash: res.data.time_series?.cash || [],
points: res.data.time_series?.points || [],
}
}
} catch {
// silent
@@ -200,10 +217,20 @@ onMounted(loadRevenue)
color: var(--color-ink-4, #8a909c);
}
.aksrt-revenue__charts {
display: grid;
gap: 24px;
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart h4 {
margin: 0 0 10px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__chart-bars {
display: flex;
align-items: flex-end;
@@ -2,46 +2,47 @@ export const revenueSchema = {
key: 'payment.revenue',
title: '创作分成',
icon: 'fas fa-chart-line',
description: '配置作者分成比例、结算门槛和结算周期。',
description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。',
defaults: {
pay_income_s: false,
payment_revenue_share_ratio: 70,
ratio_vip_1: 0,
ratio_vip_2: 0,
payment_revenue_settlement_threshold: 100,
payment_revenue_cycle_days: 30,
payment_revenue_settlement_mode: 'automatic',
payment_revenue_settlement_delay_hours: 24,
payment_revenue_points_rule: '',
payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。',
pay_income_desc_details: '',
pay_income_desc_page: '',
payment_revenue_note: '<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>',
payment_revenue_cash_ratio_user: 60,
payment_revenue_cash_ratio_vip_1: 70,
payment_revenue_cash_ratio_vip_2: 80,
payment_revenue_points_ratio_user: 0,
payment_revenue_points_ratio_vip_1: 0,
payment_revenue_points_ratio_vip_2: 0,
},
groups: [
{
key: 'policy',
title: '分成策略',
icon: 'fas fa-percentage',
key: 'basic',
title: '创作分成设置',
icon: 'fas fa-cog',
fields: [
{ key: 'pay_income_s', label: '启用创作分成结构', type: 'switch', default: false },
{ key: 'payment_revenue_share_ratio', label: '作者分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'ratio_vip_1', label: 'VIP1分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'ratio_vip_2', label: 'VIP2分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_threshold', label: '最小结算金额', type: 'number', default: 100, min: 0, max: 100000, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'pay_income_s', label: '启用创作分成', type: 'switch', default: false },
{ key: 'payment_revenue_cycle_days', label: '结算周期(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_mode', label: '结算模式', type: 'select', default: 'automatic', options: [{ value: 'automatic', label: '自动结算' }, { value: 'manual', label: '手动结算' }], dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_delay_hours', label: '结算延迟(小时)', type: 'number', default: 24, min: 0, max: 8760, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_rule', label: '积分规则', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_note', label: '结算备注', type: 'textarea', default: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_note', label: '结算备注文案', type: 'textarea', sanitize_type: 'html', default: '<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', help: '支持安全 HTML,结算后会展示在前台创作中心。', dependsOn: { key: 'pay_income_s', value: true } },
],
},
{
key: 'display',
title: '展示设置',
icon: 'fas fa-file-alt',
key: 'cash',
title: '现金收益分成',
icon: 'fas fa-wallet',
fields: [
{ key: 'pay_income_desc_details', label: '收益明细文案', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'pay_income_desc_page', label: '收益说明页', type: 'input', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
],
},
{
key: 'points',
title: '积分收益分成',
icon: 'fas fa-coins',
fields: [
{ key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
],
},
],
+41 -2
View File
@@ -710,8 +710,47 @@
}
/* ==================== 作者收益中心 ==================== */
.creator-income { display:flex; flex-direction:column; gap:16px; }.creator-income__hero { padding:24px 26px; border-radius:var(--radius-lg); color:#fff; background:linear-gradient(135deg,var(--color-primary),#7c3aed); }.creator-income__hero p { margin:0 0 6px; opacity:.82; font-size:13px; }.creator-income__hero h2 { margin:0; color:inherit; }.creator-income__tabs { display:flex; gap:8px; flex-wrap:wrap; }.creator-income__tab { padding:7px 12px; border-radius:var(--radius-pill); color:var(--color-ink-3); text-decoration:none; }.creator-income__tab.is-active,.creator-income__tab:hover { color:var(--color-primary); background:var(--color-primary-soft); }.creator-income__cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }.creator-income-card { padding:18px; border:1px solid var(--color-line); border-radius:var(--radius-lg); background:var(--surface-1); }.creator-income-card h3 { margin:0 0 14px; }.creator-income-card__metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.creator-income-card__metrics strong,.creator-income-card__metrics small { display:block; }.creator-income-card__metrics strong { color:var(--color-ink); font-size:18px; }.creator-income-card__metrics small { margin-top:4px; color:var(--color-ink-4); font-size:11px; }.creator-income__info,.creator-income__details { padding:20px; }.creator-income__info h3,.creator-income__toolbar h3 { margin:0; }.creator-income__info div,.creator-income__toolbar p { margin:6px 0 0; color:var(--color-ink-4); font-size:13px; }.creator-income__toolbar { display:flex; justify-content:space-between; align-items:flex-end; gap:12px; margin-bottom:14px; }.creator-income__table-wrap { overflow-x:auto; }.creator-income__table { width:100%; border-collapse:collapse; font-size:12px; }.creator-income__table th,.creator-income__table td { padding:10px 8px; border-bottom:1px solid var(--color-line); text-align:left; white-space:nowrap; }.creator-income-status { padding:3px 7px; border-radius:var(--radius-pill); color:var(--color-primary); background:var(--color-primary-soft); }.creator-income-status--cancelled,.creator-income-status--reversed { color:var(--color-ink-4); background:var(--surface-2); }.creator-income-status--pending_recovery { color:var(--color-error); background:rgba(220,38,38,.08); }.creator-income__empty { padding:40px 20px; text-align:center; color:var(--color-ink-4); }.creator-income__empty i { font-size:28px; color:var(--color-primary); } .creator-income__details select { margin-left:6px; }
@media (max-width:767px) { .creator-income__cards { grid-template-columns:1fr; }.creator-income-card__metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.creator-income__toolbar { align-items:flex-start; flex-direction:column; } }
.creator-income { display: flex; flex-direction: column; gap: 16px; }
.creator-income__cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.creator-income-card { padding: 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--surface-1); }
.creator-income-card h2 { margin: 0 0 14px; font-size: 17px; }
.creator-income-card--cny h2 { color: var(--color-primary); }
.creator-income-card--points h2 { color: #16816c; }
.creator-income-card__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.creator-income-card__metrics strong,
.creator-income-card__metrics small { display: block; }
.creator-income-card__metrics strong { color: var(--color-ink); font-size: 18px; font-variant-numeric: tabular-nums; }
.creator-income-card__metrics small { margin-top: 4px; color: var(--color-ink-4); font-size: 11px; }
.creator-income__info { padding: 20px; }
.creator-income__info h2,
.creator-income__toolbar h2 { margin: 0; font-size: 16px; }
.creator-income__info div,
.creator-income__toolbar p { margin: 6px 0 0; color: var(--color-ink-4); font-size: 13px; line-height: 1.7; }
.creator-income__details { padding: 4px 0 0; }
.creator-income__toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.creator-income__list { border-top: 1px solid var(--color-line); }
.creator-income-row { display: grid; grid-template-columns: minmax(110px, .8fr) minmax(0, 1fr) auto; align-items: center; gap: 14px; min-width: 0; padding: 14px 0; border-bottom: 1px solid var(--color-line); }
.creator-income-row__currency { color: var(--color-ink-3); font-size: 13px; }
.creator-income-row__amount { min-width: 0; overflow: hidden; color: #16816c; font-size: 15px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.creator-income-row__status { justify-self: end; padding: 3px 8px; border-radius: var(--radius-pill); color: var(--color-ink-4); background: var(--surface-2); font-size: 11px; white-space: nowrap; }
.creator-income-row__status--settled { color: #16816c; background: rgba(22, 129, 108, .1); }
.creator-income-row__status--pending { color: #a86d28; background: rgba(168, 109, 40, .12); }
.creator-income__pagination { display: flex; justify-content: center; gap: 6px; padding-top: 16px; }
.creator-income__pagination a { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 6px; color: var(--color-ink-3); background: var(--surface-2); font-size: 12px; text-decoration: none; }
.creator-income__pagination a.is-active,
.creator-income__pagination a:hover { color: #fff; background: var(--color-primary); }
.creator-income__empty { padding: 40px 20px; color: var(--color-ink-4); text-align: center; }
.creator-income__empty i { display: block; margin-bottom: 8px; color: var(--color-primary); font-size: 28px; }
@media (max-width: 767px) {
.creator-income__cards { grid-template-columns: 1fr; }
.creator-income-card__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
.creator-income-row { grid-template-columns: minmax(82px, .8fr) minmax(0, 1fr) auto; gap: 8px; }
.creator-income-row__currency,
.creator-income-row__amount { font-size: 13px; }
.creator-income-row__status { padding-inline: 6px; }
}
/* ==================== 独立创作中心工作台 ==================== */
.creator-workspace { display: flex; flex-direction: column; gap: 16px; }
+1 -1
View File
@@ -81,7 +81,7 @@
var url = restUrl.replace(/\/$/, '') + '/user-center/panel/' + encodeURIComponent(name);
if (name === 'creator') {
var params = new URLSearchParams(window.location.search);
['income_currency', 'income_status', 'income_page'].forEach(function(key) {
['income_page'].forEach(function(key) {
if (params.get(key)) url += (url.indexOf('?') === -1 ? '?' : '&') + key + '=' + encodeURIComponent(params.get(key));
});
}
+2 -2
View File
@@ -109,7 +109,7 @@ if (!function_exists('aksrt_get_user_center_tab_url')) {
$url = remove_query_arg([
'tab', 'posts_page', 'comments_page', 'favorites_page', 'credits_page',
'post_status', 'creator_page', 'creator_status', 'saved', 'profile_error',
'order_tab', 'otab', 'order_status', 'order_page', 'order_type', 'section',
'order_tab', 'otab', 'order_status', 'order_page', 'order_type', 'income_currency', 'income_status', 'income_page', 'section',
], aksrt_get_user_center_url());
return add_query_arg('tab', $tab, $url);
@@ -480,7 +480,7 @@ if (!function_exists('aksrt_get_user_center_render_context')) {
$post_status_filter = 'publish';
}
$user_status_counts = ['publish' => 0, 'draft' => 0, 'pending' => 0, 'private' => 0];
if ('posts' === $active_tab || 'creator' === $active_tab) {
if ('posts' === $active_tab) {
$status = aksrt_user_center_post_status_counts($user_id);
$user_status_counts = $status['counts'];
$post_count = $status['total'];
@@ -13,35 +13,102 @@ if ($user_id <= 0) {
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('请先登录', 'aksrt') . '</div>';
return;
}
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_revenue_settings() : [];
$settings = class_exists('\\AKSRT\\Settings\\Settings')
? \AKSRT\Settings\Settings::get_payment_revenue_settings()
: [];
if (empty($settings['enabled'])) {
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('创作收益功能暂未开启。', 'aksrt') . '</div>';
return;
}
$request = is_array($context['creator_income_request'] ?? null) ? $context['creator_income_request'] : [];
$currency = sanitize_key((string) ($request['currency'] ?? ($_GET['income_currency'] ?? '')));
$status = sanitize_key((string) ($request['status'] ?? ($_GET['income_status'] ?? '')));
$currency = in_array($currency, ['', 'CNY', 'points'], true) ? $currency : '';
$status = in_array($status, ['', 'pending', 'settled', 'cancelled', 'reversed', 'pending_recovery'], true) ? $status : '';
$page = max(1, min(100, (int) ($request['page'] ?? ($_GET['income_page'] ?? 1))));
$view = class_exists('\AKSRT\Payment\Revenue') ? \AKSRT\Payment\Revenue::get_instance()->get_user_income_view($user_id, ['currency' => $currency, 'status' => $status, 'page' => $page]) : ['stats' => [], 'items' => [], 'max_pages' => 1, 'page' => 1];
$view = class_exists('\\AKSRT\\Payment\\Revenue')
? \AKSRT\Payment\Revenue::get_instance()->get_user_income_view($user_id, ['page' => $page])
: ['stats' => [], 'items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1];
$stats = (array) ($view['stats'] ?? []);
$items = (array) ($view['items'] ?? []);
$base_url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('creator') : home_url('/profile/?tab=creator');
$filter_url = static function (array $extra) use ($base_url): string { return add_query_arg(array_merge(['tab' => 'creator'], $extra), remove_query_arg(['income_page'])); };
$format = static function ($amount, string $unit): string { return number_format_i18n((float) $amount, $unit === 'points' ? 0 : 2); };
$status_labels = ['pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'reversed' => __('已冲正', 'aksrt'), 'pending_recovery' => __('待追缴', 'aksrt')];
$base_url = function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('creator')
: home_url('/profile/?tab=creator');
$page_url = static function (int $target_page) use ($base_url): string {
return add_query_arg(
['tab' => 'creator', 'income_page' => max(1, $target_page)],
remove_query_arg(['income_currency', 'income_status', 'income_page'], $base_url)
);
};
$format = static function ($amount, string $unit): string {
return number_format_i18n((float) $amount, $unit === 'points' ? 0 : 2);
};
$note = trim((string) ($settings['note'] ?? ''));
if ($note === '') {
$note = __('<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', 'aksrt');
}
$status_labels = [
'pending' => __('待结算', 'aksrt'),
'settled' => __('已结算', 'aksrt'),
];
$currency_labels = [
'CNY' => __('现金收益', 'aksrt'),
'points' => __('积分收益', 'aksrt'),
];
?>
<div class="creator-income" data-creator-income>
<header class="creator-income__hero"><p><?php esc_html_e('作者收益', 'aksrt'); ?></p><h2><?php esc_html_e('创作中心', 'aksrt'); ?></h2><p><?php esc_html_e('查看付费内容带来的现金和积分收益。', 'aksrt'); ?></p></header>
<nav class="creator-income__tabs" aria-label="<?php esc_attr_e('收益货币筛选', 'aksrt'); ?>">
<?php foreach (['' => __('全部', 'aksrt'), 'CNY' => __('现金收益', 'aksrt'), 'points' => __('积分收益', 'aksrt')] as $key => $label) : ?><a class="creator-income__tab<?php echo $currency === $key ? ' is-active' : ''; ?>" href="<?php echo esc_url($filter_url(['income_currency' => $key, 'income_status' => $status])); ?>"><?php echo esc_html($label); ?></a><?php endforeach; ?>
</nav>
<section class="creator-income__cards" aria-label="<?php esc_attr_e('收益概览', 'aksrt'); ?>">
<?php foreach (['CNY' => __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')] as $unit => $label) : ?><article class="creator-income-card"><h3><?php echo esc_html($label); ?></h3><div class="creator-income-card__metrics"><?php foreach (['today' => __('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt')] as $metric => $metric_label) : ?><span><strong><?php echo esc_html($format($stats[$metric][$unit] ?? 0, $unit)); ?></strong><small><?php echo esc_html($metric_label); ?></small></span><?php endforeach; ?></div></article><?php endforeach; ?>
<?php foreach (['CNY' => __('现金收益', 'aksrt'), 'points' => __('积分收益', 'aksrt')] as $unit => $label) : ?>
<article class="creator-income-card creator-income-card--<?php echo esc_attr(strtolower($unit)); ?>">
<h2><?php echo esc_html($label); ?></h2>
<div class="creator-income-card__metrics">
<?php foreach (['today' => __('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt')] as $metric => $metric_label) : ?>
<span>
<strong><?php echo esc_html($format($stats[$metric][$unit] ?? 0, $unit)); ?></strong>
<small><?php echo esc_html($metric_label); ?></small>
</span>
<?php endforeach; ?>
</div>
</article>
<?php endforeach; ?>
</section>
<section class="creator-income__info widget-card"><h3><?php esc_html_e('创作分成说明', 'aksrt'); ?></h3><div><?php echo wp_kses((string) ($settings['description'] ?? $settings['note'] ?? __('订单完成并经过结算延迟后,作者分成会进入对应的钱包。退款订单会取消或冲正收益。', 'aksrt')), ['p' => [], 'br' => [], 'strong' => [], 'em' => [], 'ul' => [], 'ol' => [], 'li' => []]); ?></div></section>
<section class="creator-income__details widget-card"><div class="creator-income__toolbar"><div><h3><?php esc_html_e('收入明细', 'aksrt'); ?></h3><p><?php esc_html_e('按产生时间倒序显示,每页 20 条。', 'aksrt'); ?></p></div><form method="get" action="<?php echo esc_url($base_url); ?>"><input type="hidden" name="tab" value="creator" /><input type="hidden" name="income_currency" value="<?php echo esc_attr($currency); ?>" /><label><?php esc_html_e('状态', 'aksrt'); ?> <select name="income_status" onchange="this.form.submit()"><option value=""><?php esc_html_e('全部状态', 'aksrt'); ?></option><?php foreach ($status_labels as $key => $label) : ?><option value="<?php echo esc_attr($key); ?>"<?php selected($status, $key); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select></label></form></div>
<?php if (!$items) : ?><div class="creator-income__empty"><i class="fas fa-chart-line" aria-hidden="true"></i><p><?php esc_html_e('暂无收益明细', 'aksrt'); ?></p></div><?php else : ?><div class="creator-income__table-wrap"><table class="creator-income__table"><thead><tr><th><?php esc_html_e('文章', 'aksrt'); ?></th><th><?php esc_html_e('订单', 'aksrt'); ?></th><th><?php esc_html_e('支付金额', 'aksrt'); ?></th><th><?php esc_html_e('作者分成', 'aksrt'); ?></th><th><?php esc_html_e('状态', 'aksrt'); ?></th><th><?php esc_html_e('产生时间', 'aksrt'); ?></th></tr></thead><tbody><?php foreach ($items as $item) : $post = get_post((int) ($item['post_id'] ?? 0)); $unit = (string) ($item['currency'] ?? 'CNY'); ?><tr><td><?php echo esc_html($post ? get_the_title($post) : __('已删除文章', 'aksrt')); ?></td><td><code><?php echo esc_html((string) ($item['order_number'] ?? '')); ?></code></td><td><?php echo esc_html($format($item['original_amount'] ?? 0, $unit) . ' ' . $unit); ?></td><td><?php echo esc_html($format($item['amount'] ?? 0, $unit) . ' ' . $unit); ?></td><td><span class="creator-income-status creator-income-status--<?php echo esc_attr(sanitize_key((string) ($item['status'] ?? ''))); ?>"><?php echo esc_html($status_labels[$item['status'] ?? ''] ?? (string) ($item['status'] ?? '')); ?></span></td><td><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?>
<section class="creator-income__info widget-card">
<h2><?php esc_html_e('结算备注', 'aksrt'); ?></h2>
<div><?php echo wp_kses_post($note); ?></div>
</section>
<section class="creator-income__details">
<div class="creator-income__toolbar">
<div>
<h2><?php esc_html_e('收入明细', 'aksrt'); ?></h2>
<p><?php esc_html_e('按产生时间倒序显示,每页 20 条。', 'aksrt'); ?></p>
</div>
</div>
<?php if (!$items) : ?>
<div class="creator-income__empty">
<i class="fas fa-chart-line" aria-hidden="true"></i>
<p><?php esc_html_e('暂无收益明细', 'aksrt'); ?></p>
</div>
<?php else : ?>
<div class="creator-income__list" role="list" aria-label="<?php esc_attr_e('收入明细', 'aksrt'); ?>">
<?php foreach ($items as $item) :
$unit = (string) ($item['currency'] ?? 'CNY') === 'points' ? 'points' : 'CNY';
$status = (string) ($item['status'] ?? '') === 'pending' ? 'pending' : 'settled';
$amount = $format($item['amount'] ?? 0, $unit);
?>
<article class="creator-income-row" role="listitem">
<span class="creator-income-row__currency"><?php echo esc_html($currency_labels[$unit === 'points' ? 'points' : 'CNY']); ?></span>
<strong class="creator-income-row__amount">+<?php echo esc_html($amount . ($unit === 'points' ? __('积分', 'aksrt') : __('现金', 'aksrt'))); ?></strong>
<span class="creator-income-row__status creator-income-row__status--<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status]); ?></span>
</article>
<?php endforeach; ?>
</div>
<?php if ((int) ($view['max_pages'] ?? 1) > 1) : ?>
<nav class="creator-income__pagination" aria-label="<?php esc_attr_e('收入明细分页', 'aksrt'); ?>">
<?php for ($i = 1; $i <= (int) $view['max_pages']; $i++) : ?>
<a class="<?php echo $i === (int) ($view['page'] ?? $page) ? 'is-active' : ''; ?>" href="<?php echo esc_url($page_url($i)); ?>"><?php echo esc_html((string) $i); ?></a>
<?php endfor; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</section>
</div>
+14 -7
View File
@@ -51,12 +51,16 @@ class PayIncome {
$svc = \AKSRT\Payment\Revenue::get_instance();
$stats = $user_id > 0 ? $svc->get_stats($user_id) : $svc->get_stats();
$cash_stats = (array) ($stats['cash'] ?? []);
$points_stats = (array) ($stats['points'] ?? []);
echo '<p>' . esc_html(sprintf(
/* translators: 1: total 2: pending 3: settled */
__('合计 %1$s · 待结算 %2$s · 已结算 %3$s', 'aksrt'),
number_format((float) ($stats['total'] ?? 0), 2),
number_format((float) ($stats['pending'] ?? 0), 2),
number_format((float) ($stats['settled'] ?? 0), 2)
__('现金:合计 ¥%1$s · 待结算 ¥%2$s · 已结算 ¥%3$s;积分:合计 %4$s · 待结算 %5$s · 已结算 %6$s', 'aksrt'),
number_format((float) ($cash_stats['total'] ?? 0), 2),
number_format((float) ($cash_stats['pending'] ?? 0), 2),
number_format((float) ($cash_stats['settled'] ?? 0), 2),
number_format((float) ($points_stats['total'] ?? 0), 0),
number_format((float) ($points_stats['pending'] ?? 0), 0),
number_format((float) ($points_stats['settled'] ?? 0), 0)
)) . '</p>';
echo '<form id="aksrt-pay-batch-settle-income" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:12px 0;">';
@@ -68,7 +72,7 @@ class PayIncome {
echo '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="aksrt_income" />';
echo '<select name="status">';
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'reversed' => __('已冲正', 'aksrt'), 'pending_recovery' => __('待追缴', 'aksrt')] as $key => $label) {
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt')] as $key => $label) {
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
}
echo '</select> ';
@@ -116,7 +120,10 @@ class PayIncome {
echo '<td>' . ($st === 'pending' ? '<input type="checkbox" form="aksrt-pay-batch-settle-income" name="revenue_ids[]" value="' . esc_attr((string) $id) . '" />' : '') . '</td>';
echo '<td>' . esc_html((string) $id) . '</td><td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>';
$is_points = (string) ($row['currency'] ?? 'CNY') === 'points';
$row_currency = $is_points ? __('积分', 'aksrt') : '¥';
$row_decimals = $is_points ? 0 : 2;
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), $row_decimals) . ' ' . $row_currency) . '</td>';
echo '<td>' . esc_html($st) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '<td>';
+21 -37
View File
@@ -48,11 +48,6 @@ class PaymentController extends ControllerBase {
'callback' => [$this, 'settle_creator_income'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/payment/creator-income/order/(?P<order_id>[a-zA-Z0-9_-]+)/reverse', [
'methods' => 'POST',
'callback' => [$this, 'reverse_creator_income'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [
'methods' => 'GET',
@@ -160,7 +155,10 @@ class PaymentController extends ControllerBase {
* GET /payment/revenue — revenue stats with optional time-series
*/
public function get_revenue(\WP_REST_Request $request): \WP_REST_Response {
$revenue_stats = ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0];
$revenue_stats = [
'cash' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
];
if (class_exists('\AKSRT\Payment\Revenue')) {
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats();
}
@@ -194,12 +192,14 @@ class PaymentController extends ControllerBase {
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
$granularity = sanitize_text_field((string) $request->get_param('granularity'));
// Default: last 30 days
// Default: last 30 days in the WordPress site timezone.
$timezone = function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');
$now = new \DateTimeImmutable('now', $timezone);
if (!$date_from) {
$date_from = gmdate('Y-m-d', strtotime('-30 days'));
$date_from = $now->modify('-30 days')->format('Y-m-d');
}
if (!$date_to) {
$date_to = gmdate('Y-m-d');
$date_to = $now->format('Y-m-d');
}
// Granularity: day, week, month
@@ -212,27 +212,27 @@ class PaymentController extends ControllerBase {
$table = $prefix . 'aksrt_revenue';
$results = $wpdb->get_results($wpdb->prepare(
"SELECT DATE_FORMAT(created_at, %s) AS period,
"SELECT currency, DATE_FORMAT(created_at, %s) AS period,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM {$table}
WHERE created_at >= %s AND created_at < DATE_ADD(%s, INTERVAL 1 DAY)
GROUP BY period
WHERE status IN ('pending', 'settled')
AND created_at >= %s AND created_at < DATE_ADD(%s, INTERVAL 1 DAY)
GROUP BY currency, period
ORDER BY period ASC",
$date_format,
$date_from,
$date_to
));
$series = [];
if ($results) {
foreach ($results as $row) {
$series[] = [
'date' => (string) $row->period,
'amount' => (float) $row->total_amount,
'count' => (int) $row->order_count,
];
}
$series = ['cash' => [], 'points' => []];
foreach ((array) $results as $row) {
$key = (string) ($row->currency ?? '') === 'points' ? 'points' : 'cash';
$series[$key][] = [
'date' => (string) $row->period,
'amount' => (float) $row->total_amount,
'count' => (int) $row->order_count,
];
}
return $series;
@@ -277,22 +277,6 @@ class PaymentController extends ControllerBase {
return $this->success(['message' => __('创作分成已结算', 'aksrt')]);
}
/**
* POST /payment/creator-income/order/{order_id}/reverse — cancel or reverse order income.
*/
public function reverse_creator_income(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Revenue')) {
return $this->error(__('创作分成模块未启用', 'aksrt'), 500);
}
$order_id = sanitize_text_field((string) $request->get_param('order_id'));
$params = $this->get_json_params($request);
$reason = sanitize_textarea_field((string) ($params['reason'] ?? ''));
if ($order_id === '' || !\AKSRT\Payment\Revenue::get_instance()->cancel_or_reverse($order_id, $reason)) {
return $this->error(__('冲正失败', 'aksrt'));
}
return $this->success(['message' => __('创作分成已处理退款/冲正', 'aksrt')]);
}
public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]);
+13 -14
View File
@@ -164,24 +164,23 @@ class SchemaPayment {
'revenue' => [
'key' => 'revenue',
'title' => '创作分成',
'version' => '1.0.0',
'version' => '2.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'policy', 'title' => '分成策略', 'fields' => [
['key' => 'basic', 'title' => '创作分成设置', 'fields' => [
['key' => 'pay_income_s', 'label' => '启用创作分成', 'type' => 'switch', 'default' => false],
['key' => 'payment_revenue_share_ratio', 'label' => '作者分成比例(%)', 'type' => 'number', 'default' => 70, 'min' => 1, 'max' => 100],
['key' => 'ratio_vip_1', 'label' => 'VIP1分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
['key' => 'ratio_vip_2', 'label' => 'VIP2分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
['key' => 'payment_revenue_settlement_threshold', 'label' => '最小结算金额', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 99999],
['key' => 'payment_revenue_cycle_days', 'label' => '结算周期(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365],
['key' => 'payment_revenue_settlement_mode', 'label' => '结算模式', 'type' => 'select', 'default' => 'automatic', 'options' => [['value' => 'automatic', 'label' => '自动结算'], ['value' => 'manual', 'label' => '手动结算']]],
['key' => 'payment_revenue_settlement_delay_hours', 'label' => '结算延迟(小时)', 'type' => 'number', 'default' => 24, 'min' => 0, 'max' => 8760],
['key' => 'payment_revenue_points_rule', 'label' => '积分规则', 'type' => 'textarea', 'default' => ''],
['key' => 'payment_revenue_note', 'label' => '结算备注', 'type' => 'textarea', 'default' => ''],
['key' => 'payment_revenue_cycle_days', 'label' => '结算周期(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
['key' => 'payment_revenue_note', 'label' => '结算备注文案', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
]],
['key' => 'display', 'title' => '展示设置', 'fields' => [
['key' => 'pay_income_desc_details', 'label' => '收益明细文案', 'type' => 'textarea', 'default' => ''],
['key' => 'pay_income_desc_page', 'label' => '收益说明页', 'type' => 'input', 'default' => ''],
['key' => 'cash', 'title' => '现金收益分成', 'fields' => [
['key' => 'payment_revenue_cash_ratio_user', 'label' => '普通用户分成比例(%)', 'type' => 'number', 'default' => 60, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
['key' => 'payment_revenue_cash_ratio_vip_1', 'label' => '一级会员分成比例(%)', 'type' => 'number', 'default' => 70, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
['key' => 'payment_revenue_cash_ratio_vip_2', 'label' => '二级会员分成比例(%)', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
]],
['key' => 'points', 'title' => '积分收益分成', 'fields' => [
['key' => 'payment_revenue_points_ratio_user', 'label' => '普通用户分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
['key' => 'payment_revenue_points_ratio_vip_1', 'label' => '一级会员分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
['key' => 'payment_revenue_points_ratio_vip_2', 'label' => '二级会员分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_income_s', 'value' => true]],
]],
],
],
-7
View File
@@ -20,7 +20,6 @@ class CreatorRevenue {
public function init(): void {
add_action('aksrt_creator_income_settlement', [$this, 'settle_due']);
add_action('aksrt_order_status_changed', [$this, 'handle_order_status_changed'], 10, 3);
if (!wp_next_scheduled('aksrt_creator_income_settlement')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_creator_income_settlement');
}
@@ -32,12 +31,6 @@ class CreatorRevenue {
}
}
public function handle_order_status_changed(string $order_number, string $new_status, array $order = []): void {
if (in_array($new_status, ['cancelled', 'refunding', 'refunded'], true) && class_exists('AKSRT\\Payment\\Revenue')) {
Revenue::get_instance()->cancel_or_reverse($order_number, sprintf(__('订单状态变更为%s', 'aksrt'), $new_status));
}
}
/**
* Create creator income after content entitlement has been delivered.
*
+58 -120
View File
@@ -66,7 +66,7 @@ class Revenue {
$author_level = function_exists('aksrt_get_user_vip_level')
? max(0, (int) aksrt_get_user_vip_level($author_id))
: 0;
$ratio = $this->resolve_share_ratio($author_level);
$ratio = $this->resolve_share_ratio($currency, $author_level);
if ($ratio <= 0) {
return 0;
}
@@ -157,7 +157,7 @@ class Revenue {
}
/**
* Settle all due pending records in automatic mode.
* Settle all pending records whose rolling earning cycle has ended.
*
* @return int Number of records settled.
*/
@@ -168,15 +168,15 @@ class Revenue {
return 0;
}
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
if (($settings['settlement_mode'] ?? 'automatic') !== 'automatic') {
return 0;
}
$cycle_days = max(1, (int) ($settings['cycle_days'] ?? 30));
$timezone = function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');
$cutoff = (new \DateTimeImmutable('now', $timezone))
->modify('-' . $cycle_days . ' days')
->format('Y-m-d H:i:s');
$limit = max(1, min(500, $limit));
$delay_hours = max(0, (int) ($settings['settlement_delay_hours'] ?? 24));
$cutoff = gmdate('Y-m-d H:i:s', time() - ($delay_hours * HOUR_IN_SECONDS));
$ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE status = %s AND created_at <= %s ORDER BY id ASC LIMIT %d",
"SELECT id FROM {$this->table()} WHERE status = %s AND created_at < %s ORDER BY id ASC LIMIT %d",
'pending',
$cutoff,
$limit
@@ -191,100 +191,6 @@ class Revenue {
return $settled;
}
/**
* Cancel a pending record or reverse a settled record for a refunded order.
*
* @param string|int $order_id Order number or local order id.
*/
public function cancel_or_reverse($order_id, string $reason = ''): bool {
global $wpdb;
$order_number = sanitize_text_field((string) $order_id);
if ($order_number === '') {
return false;
}
$reason = sanitize_textarea_field($reason);
$wpdb->query('START TRANSACTION');
try {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
if (!is_array($rows) || !$rows) {
$wpdb->query('COMMIT');
return true;
}
foreach ($rows as $row) {
$status = (string) ($row['status'] ?? '');
$id = (int) ($row['id'] ?? 0);
if (in_array($status, ['cancelled', 'reversed'], true)) {
continue;
}
if ($status === 'pending') {
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'cancelled',
$reason,
$this->audit_json('cancelled', $reason),
$id,
'pending'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_cancel_failed');
}
continue;
}
if (!in_array($status, ['settled', 'pending_recovery'], true)) {
continue;
}
if ($status === 'pending_recovery') {
continue;
}
$amount = round((float) ($row['amount'] ?? 0), 2);
$user_id = (int) ($row['author_id'] ?? 0);
$ref = ['type' => 'creator_income_reversal', 'id' => $id];
$debited = (string) ($row['currency'] ?? '') === 'points'
? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::deduct($user_id, (int) ceil($amount), 'creator_income_reversal', $ref, $reason) : new \WP_Error('credits_unavailable'))
: (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::deduct($user_id, $amount, 'creator_income_reversal', $ref, $reason) : new \WP_Error('balance_unavailable'));
if (is_wp_error($debited)) {
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, reversal_amount = %f, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'pending_recovery',
$amount,
sanitize_textarea_field($debited->get_error_message()),
$this->audit_json('pending_recovery', $debited->get_error_message()),
$id,
'settled'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_recovery_failed');
}
continue;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, reversal_amount = %f, reversed_at = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'reversed',
$amount,
current_time('mysql'),
$reason,
$this->audit_json('reversed', $reason),
$id,
'settled'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_reverse_failed');
}
}
$wpdb->query('COMMIT');
return true;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return false;
}
}
private function settle_one(int $revenue_id, int $actor_id): bool {
global $wpdb;
$wpdb->query('START TRANSACTION');
@@ -370,10 +276,10 @@ class Revenue {
if ($user_id <= 0) {
return [];
}
$where = ['author_id = %d'];
$where = ["author_id = %d", "status IN ('pending', 'settled')"];
$params = [$user_id];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
if (in_array($status, ['pending', 'settled'], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
@@ -416,15 +322,17 @@ class Revenue {
$where .= ' AND currency = %s';
$params[] = $currency;
}
$today = current_time('Y-m-d');
$stats_params = array_merge($params, [$today]);
$stats_row = $wpdb->get_results($wpdb->prepare(
"SELECT currency,
COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') THEN amount ELSE 0 END), 0) total_amount,
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) pending_amount,
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) settled_amount,
COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') AND DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) today_amount
COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') AND DATE(created_at) = %s THEN amount ELSE 0 END), 0) today_amount
FROM {$this->table()} WHERE {$where}
GROUP BY currency",
$params
$stats_params
), ARRAY_A);
foreach ((array) $stats_row as $row) {
$key = (string) ($row['currency'] ?? '');
@@ -438,11 +346,14 @@ class Revenue {
}
$status = sanitize_key((string) ($args['status'] ?? ''));
if (!in_array($status, ['pending', 'settled'], true)) {
$status = '';
}
$list_args = ['currency' => $currency, 'status' => $status, 'limit' => 20, 'offset' => 0];
$page = max(1, min(100, (int) ($args['page'] ?? 1)));
$list_args['offset'] = ($page - 1) * 20;
$items = $this->get_user_revenues($user_id, $list_args);
$count_where = $where . ($status !== '' ? ' AND status = %s' : '');
$count_where = $where . " AND status IN ('pending', 'settled')" . ($status !== '' ? ' AND status = %s' : '');
$count_params = $params;
if ($status !== '') {
$count_params[] = $status;
@@ -462,9 +373,10 @@ class Revenue {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where = [];
$where = ["status IN ('pending', 'settled')"];
$params = [];
if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') {
$status = sanitize_key((string) ($args['status'] ?? ''));
if (in_array($status, ['pending', 'settled'], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
@@ -500,22 +412,48 @@ class Revenue {
return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
}
/**
* Return separate cash and points totals; amounts must never be added across units.
*
* @return array{cash:array{total:float,pending:float,settled:float,count:int},points:array{total:float,pending:float,settled:float,count:int}}
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$where = $user_id > 0 ? 'WHERE author_id = %d' : '';
$params = $user_id > 0 ? [$user_id] : [];
$row = $params ? $wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()} {$where}", $params), ARRAY_A) : $wpdb->get_row("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()}", ARRAY_A);
return ['total' => (float) ($row['total'] ?? 0), 'pending' => (float) ($row['pending'] ?? 0), 'settled' => (float) ($row['settled'] ?? 0), 'count' => (int) ($row['count'] ?? 0)];
$empty = [
'cash' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
'points' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
];
$where = ["status IN ('pending', 'settled')"];
$params = [];
if ($user_id > 0) {
$where[] = 'author_id = %d';
$params[] = $user_id;
}
$stats_sql = "SELECT currency,
COALESCE(SUM(amount), 0) total,
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) pending,
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) settled,
COUNT(*) count
FROM {$this->table()} WHERE " . implode(' AND ', $where) . " GROUP BY currency";
$rows = $wpdb->get_results($params ? $wpdb->prepare($stats_sql, $params) : $stats_sql, ARRAY_A);
foreach ((array) $rows as $row) {
$key = (string) ($row['currency'] ?? '') === 'points' ? 'points' : 'cash';
$empty[$key] = [
'total' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'count' => (int) ($row['count'] ?? 0),
];
}
return $empty;
}
private function resolve_share_ratio(int $author_level): float {
private function resolve_share_ratio(string $currency, int $author_level): float {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$ratio = $author_level >= 2 ? 80.0 : ($author_level === 1 ? 70.0 : 60.0);
$key = $author_level >= 2 ? 'ratio_vip2' : ($author_level === 1 ? 'ratio_vip1' : 'share_ratio');
if (isset($settings[$key]) && is_numeric($settings[$key])) {
$ratio = (float) $settings[$key];
}
return max(0, min(100, $ratio));
$bucket = $currency === 'points' ? 'points' : 'cash';
$level_key = $author_level >= 2 ? 'vip2' : ($author_level === 1 ? 'vip1' : 'ordinary');
$ratio = $settings['ratios'][$bucket][$level_key] ?? 0;
return max(0, min(100, (float) $ratio));
}
private function decode_order_meta(array $order): array {
+1 -1
View File
@@ -171,7 +171,7 @@ trait CreditsPaymentGroups {
}
/**
* @return array{enabled:bool,share_ratio:int,settlement_threshold:int,cycle_days:int,note:string}
* @return array{enabled:bool,cycle_days:int,ratios:array<string,array<string,int>>,note:string}
*/
public static function get_payment_revenue_settings(): array {
return PaymentRuntime::get_payment_revenue_settings();
+18 -18
View File
@@ -237,31 +237,31 @@ class PaymentRuntime {
}
/**
* @return array<string,mixed>
* Creator-income policy. Cash and points intentionally use separate ratio sets.
*
* @return array{enabled:bool,cycle_days:int,ratios:array<string,array<string,int>>,note:string}
*/
public static function get_payment_revenue_settings(): array {
$settings = Settings::get_all();
$note = trim((string) ($settings['payment_revenue_note'] ?? ''));
$mode = sanitize_key((string) ($settings['payment_revenue_settlement_mode'] ?? 'automatic'));
if (!in_array($mode, ['automatic', 'manual'], true)) {
$mode = 'automatic';
}
$clamp_ratio = static fn ($value): int => max(0, min(100, (int) $value));
return [
'enabled' => !empty($settings['pay_income_s']),
'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 60))),
'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 70))),
'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 80))),
'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)),
'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))),
'settlement_mode' => $mode,
'settlement_delay_hours' => max(0, min(8760, (int) ($settings['payment_revenue_settlement_delay_hours'] ?? 24))),
'points_rule' => sanitize_textarea_field((string) ($settings['payment_revenue_points_rule'] ?? '')),
'note' => $note !== '' ? $note : __('分成策略和台账结构已就绪,订单入账后自动按比例结算。', 'aksrt'),
'display' => [
'desc_details' => sanitize_textarea_field((string) ($settings['pay_income_desc_details'] ?? '')),
'desc_page' => esc_url_raw((string) ($settings['pay_income_desc_page'] ?? '')),
'enabled' => !empty($settings['pay_income_s']),
'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))),
'ratios' => [
'cash' => [
'ordinary' => $clamp_ratio($settings['payment_revenue_cash_ratio_user'] ?? 60),
'vip1' => $clamp_ratio($settings['payment_revenue_cash_ratio_vip_1'] ?? 70),
'vip2' => $clamp_ratio($settings['payment_revenue_cash_ratio_vip_2'] ?? 80),
],
'points' => [
'ordinary' => $clamp_ratio($settings['payment_revenue_points_ratio_user'] ?? 0),
'vip1' => $clamp_ratio($settings['payment_revenue_points_ratio_vip_1'] ?? 0),
'vip2' => $clamp_ratio($settings['payment_revenue_points_ratio_vip_2'] ?? 0),
],
],
'note' => $note !== '' ? wp_kses_post($note) : __('<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', 'aksrt'),
];
}
+1 -5
View File
@@ -70,12 +70,8 @@ class Center {
$income_request = [];
if ($tab === 'creator') {
$currency = sanitize_key((string) $request->get_param('income_currency'));
$status = sanitize_key((string) $request->get_param('income_status'));
$income_request = [
'currency' => in_array($currency, ['', 'CNY', 'points'], true) ? $currency : '',
'status' => in_array($status, ['', 'pending', 'settled', 'cancelled', 'reversed', 'pending_recovery'], true) ? $status : '',
'page' => max(1, min(100, (int) $request->get_param('income_page'))),
'page' => max(1, min(100, (int) $request->get_param('income_page'))),
];
$context['creator_income_request'] = $income_request;
}
+16 -6
View File
@@ -72,15 +72,25 @@ class CreditsAdmin {
}
if (class_exists('\AKSRT\Payment\Revenue')) {
$stats = \AKSRT\Payment\Revenue::get_instance()->get_stats($user_id);
$pending = (float) ($stats['pending'] ?? 0);
$total = (float) ($stats['total'] ?? 0);
if ($total > 0 || $pending > 0) {
$cash = (array) ($stats['cash'] ?? []);
$points = (array) ($stats['points'] ?? []);
if ((float) ($cash['total'] ?? 0) > 0 || (float) ($cash['pending'] ?? 0) > 0) {
$bits[] = sprintf(
/* translators: 1 total 2 pending */
esc_html__('分成 ¥%1$s(待结 ¥%2$s)', 'aksrt'),
number_format($total, 2),
number_format($pending, 2)
esc_html__('现金分成 ¥%1$s(待结 ¥%2$s)', 'aksrt'),
number_format((float) ($cash['total'] ?? 0), 2),
number_format((float) ($cash['pending'] ?? 0), 2)
);
}
if ((float) ($points['total'] ?? 0) > 0 || (float) ($points['pending'] ?? 0) > 0) {
$bits[] = sprintf(
/* translators: 1 total 2 pending */
esc_html__('积分分成 %1$s(待结 %2$s)', 'aksrt'),
number_format((float) ($points['total'] ?? 0), 0),
number_format((float) ($points['pending'] ?? 0), 0)
);
}
if ($bits) {
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_income&user_id=' . $user_id)) . '">' . esc_html__('分成明细', 'aksrt') . '</a>';
}
}