feat(payment): separate creator income settlement
This commit is contained in:
@@ -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 中的收益口径、结算策略、货币处理、权限、退款保持和后台入口均已确定。
|
||||
|
||||
@@ -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-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
|
||||
v-for="(item, index) in timeSeries"
|
||||
:key="index"
|
||||
v-for="(item, index) in group.items"
|
||||
:key="`${group.key}-${index}`"
|
||||
class="aksrt-revenue__chart-bar-wrap"
|
||||
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
|
||||
:title="`${item.date}: ${formatAmount(item.amount, group.key)} (${item.count}笔)`"
|
||||
>
|
||||
<div
|
||||
class="aksrt-revenue__chart-bar"
|
||||
:style="{ height: getBarHeight(item.amount) + '%' }"
|
||||
: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>¥{{ periodTotal.toFixed(2) }}</strong></span>
|
||||
<span>共 {{ periodCount }} 笔</span>
|
||||
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
|
||||
<span>共 {{ group.count }} 笔</span>
|
||||
</div>
|
||||
</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 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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,28 +212,28 @@ 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[] = [
|
||||
$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' => []]]);
|
||||
|
||||
@@ -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]],
|
||||
]],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'] ?? '')),
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -70,11 +70,7 @@ 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'))),
|
||||
];
|
||||
$context['creator_income_request'] = $income_request;
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user