diff --git a/.specs/plans/user-creator-income-center.md b/.specs/plans/user-creator-income-center.md index 821101a0..18ca7a6f 100644 --- a/.specs/plans/user-creator-income-center.md +++ b/.specs/plans/user-creator-income-center.md @@ -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 中的收益口径、结算策略、货币处理、权限、退款保持和后台入口均已确定。 diff --git a/.specs/prds/user-creator-income-center.md b/.specs/prds/user-creator-income-center.md index c65ab946..7364aa16 100644 --- a/.specs/prds/user-creator-income-center.md +++ b/.specs/prds/user-creator-income-center.md @@ -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 格式的说明内容。默认内容为: - -> 在本站发布付费内容,获得的收益将参与收入分成
-> 发布付费内容,请注意以下事项
-> 普通用户分成比例为:60%
-> 黄金会员分成比例为:70%
-> 钻石会员分成比例为:80%
-> 分成比例的计算方式为:付费文章实际收益x分成比例
-> 获得收入超过50元后可申请现金提现
-> 申请提现后,需后台人工处理,一般2-3小时,请耐心等待
- -实际显示的比例和会员名称应根据后台当前配置渲染;收益记录使用生成时锁定的比例,不因后续配置修改而改变。 - -#### 收入明细 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 元结算门槛;该金额只用于现金提现提示。 -- 本次不允许余额因冲正而变为负数,也不实现自动追缴扣款之外的催收流程。 +- 本次不实现退款后的作者收益冲正、余额扣回或追缴。 - 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。 - 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。 - 本次不引入新的前端框架、支付网关或货币体系。 diff --git a/assets/admin/src/pages/payment/PaymentRevenue.vue b/assets/admin/src/pages/payment/PaymentRevenue.vue index 3592f272..9354a602 100644 --- a/assets/admin/src/pages/payment/PaymentRevenue.vue +++ b/assets/admin/src/pages/payment/PaymentRevenue.vue @@ -2,7 +2,7 @@ - +
- {{ revenueStats.total.toFixed(2) }} -

总收益 (元)

-
-
- -
- {{ revenueStats.pending.toFixed(2) }} -

待结算 (元)

-
-
- -
- {{ revenueStats.settled.toFixed(2) }} -

已结算 (元)

-
-
- -
- {{ revenueStats.count }} -

收益笔数

+ {{ item.value }} +

{{ item.label }}

@@ -47,24 +29,28 @@ -
-
-
+
+
+

{{ group.label }}

+
- {{ formatLabel(item.date) }} + 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}笔)`" + > +
+ {{ formatLabel(item.date) }} +
+
+ +
+ 期间总计: {{ formatAmount(group.total, group.key) }} + 共 {{ group.count }} 笔
-
-
- 期间总计: ¥{{ periodTotal.toFixed(2) }} - 共 {{ periodCount }} 笔
@@ -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; diff --git a/assets/admin/src/schema/sections/payment/revenue.js b/assets/admin/src/schema/sections/payment/revenue.js index 22ac339d..c7ffb874 100644 --- a/assets/admin/src/schema/sections/payment/revenue.js +++ b/assets/admin/src/schema/sections/payment/revenue.js @@ -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: '

订单完成并经过结算周期后,作者分成会自动转入对应的钱包。

', + 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: '

订单完成并经过结算周期后,作者分成会自动转入对应的钱包。

', 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 } }, ], }, ], diff --git a/assets/css/routes/profile/lists.css b/assets/css/routes/profile/lists.css index 70a1b809..08fdd582 100644 --- a/assets/css/routes/profile/lists.css +++ b/assets/css/routes/profile/lists.css @@ -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; } diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 5a48424e..eed259cb 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -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)); }); } diff --git a/inc/helpers/aksrt-user-center.php b/inc/helpers/aksrt-user-center.php index dc2b1002..36adb10d 100644 --- a/inc/helpers/aksrt-user-center.php +++ b/inc/helpers/aksrt-user-center.php @@ -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']; diff --git a/inc/helpers/user-center-renderers/profile-creator.php b/inc/helpers/user-center-renderers/profile-creator.php index d109796b..e73b0409 100644 --- a/inc/helpers/user-center-renderers/profile-creator.php +++ b/inc/helpers/user-center-renderers/profile-creator.php @@ -13,35 +13,102 @@ if ($user_id <= 0) { echo '
' . esc_html__('请先登录', 'aksrt') . '
'; 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 '
' . esc_html__('创作收益功能暂未开启。', 'aksrt') . '
'; 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 = __('

订单完成并经过结算周期后,作者分成会自动转入对应的钱包。

', 'aksrt'); +} +$status_labels = [ + 'pending' => __('待结算', 'aksrt'), + 'settled' => __('已结算', 'aksrt'), +]; +$currency_labels = [ + 'CNY' => __('现金收益', 'aksrt'), + 'points' => __('积分收益', 'aksrt'), +]; ?>
-

-
- __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')] as $unit => $label) : ?>

__('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt')] as $metric => $metric_label) : ?>
+ __('现金收益', 'aksrt'), 'points' => __('积分收益', 'aksrt')] as $unit => $label) : ?> +
+

+
+ __('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt')] as $metric => $metric_label) : ?> + + + + + +
+
+
-

[], 'br' => [], 'strong' => [], 'em' => [], 'ul' => [], 'ol' => [], 'li' => []]); ?>
-

-

+ +
+

+
+
+ +
+
+
+

+

+
+
+ +
+ +

+
+ +
+ +
+ + + + +
+ +
+ 1) : ?> + + +
diff --git a/src/Admin/Native/PayIncome.php b/src/Admin/Native/PayIncome.php index dc2d126d..61aae314 100644 --- a/src/Admin/Native/PayIncome.php +++ b/src/Admin/Native/PayIncome.php @@ -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 '

' . 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) )) . '

'; echo '
'; @@ -68,7 +72,7 @@ class PayIncome { echo ''; echo ''; echo ' '; @@ -116,7 +120,10 @@ class PayIncome { echo '' . ($st === 'pending' ? '' : '') . ''; echo '' . esc_html((string) $id) . '' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . ''; echo '' . esc_html((string) ($row['order_number'] ?? '')) . ''; - echo '' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ''; + $is_points = (string) ($row['currency'] ?? 'CNY') === 'points'; + $row_currency = $is_points ? __('积分', 'aksrt') : '¥'; + $row_decimals = $is_points ? 0 : 2; + echo '' . esc_html(number_format((float) ($row['amount'] ?? 0), $row_decimals) . ' ' . $row_currency) . ''; echo '' . esc_html($st) . ''; echo '' . esc_html((string) ($row['created_at'] ?? '')) . ''; echo ''; diff --git a/src/Admin/PaymentController.php b/src/Admin/PaymentController.php index 484fc1f6..b5c1e18b 100644 --- a/src/Admin/PaymentController.php +++ b/src/Admin/PaymentController.php @@ -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[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' => []]]); diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 311de06a..f616f16d 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -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' => '

订单完成并经过结算周期后,作者分成会自动转入对应的钱包。

', '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]], ]], ], ], diff --git a/src/Payment/CreatorRevenue.php b/src/Payment/CreatorRevenue.php index 45a5c6eb..338f7b5f 100644 --- a/src/Payment/CreatorRevenue.php +++ b/src/Payment/CreatorRevenue.php @@ -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. * diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index 86587935..20ab8dd2 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -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 { diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php index 37cf5cc5..934f8138 100644 --- a/src/Settings/CreditsPaymentGroups.php +++ b/src/Settings/CreditsPaymentGroups.php @@ -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>,note:string} */ public static function get_payment_revenue_settings(): array { return PaymentRuntime::get_payment_revenue_settings(); diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 9055849a..447dcf15 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -237,31 +237,31 @@ class PaymentRuntime { } /** - * @return array + * Creator-income policy. Cash and points intentionally use separate ratio sets. + * + * @return array{enabled:bool,cycle_days:int,ratios:array>,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) : __('

订单完成并经过结算周期后,作者分成会自动转入对应的钱包。

', 'aksrt'), ]; } diff --git a/src/User/Center.php b/src/User/Center.php index 894d1d09..5605dd9f 100644 --- a/src/User/Center.php +++ b/src/User/Center.php @@ -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; } diff --git a/src/User/CreditsAdmin.php b/src/User/CreditsAdmin.php index 42dd0a0c..fdef9514 100644 --- a/src/User/CreditsAdmin.php +++ b/src/User/CreditsAdmin.php @@ -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[] = '' . esc_html__('分成明细', 'aksrt') . ''; } }