feat(admin): 订单明细页已支付订单新增「退款」按钮

操作栏「详情」右侧为已支付订单渲染「退款」按钮:点击经 aksrt_pay_refund_order 把订单推到已退款。

退款沿既有的受闸路径执行:OrderRepository::update_status 要求 allow_refund,且 can_finalize_order_refund 要求该售后工单仍为待审核/处理中。因此工单号由服务端按订单号反查 find_active 得到,客户端只提交订单号;没有进行中的工单时整次操作中止并给出可操作提示,不会出现订单已退款但无工单依据的状态。

按钮仅在已支付订单上渲染——已退款是已支付的唯一后继状态,其他状态点击也只会在服务端被拒。附带清理 AfterSaleService::find_active 上方一处早已错配的 @param 文档注释(描述的是 \,与该函数签名无关)。
This commit is contained in:
2026-10-08 21:14:56 +08:00
parent 5fd8c6fc31
commit 83c164a427
4 changed files with 188 additions and 9 deletions
+107 -7
View File
@@ -1,8 +1,9 @@
<?php
// Dev harness: render the REAL PayOrders::render_after_sales_page() with WP stubs,
// and drive PayOrders::handle_after_sale_status() through the 「标记订单已退款」 flow.
// Dev harness: render the REAL PayOrders pages with WP stubs, and drive the refund
// handlers through their scenarios.
// Usage:
// php render-after-sales-harness.php -> render page, write after-sales-rendered.html
// php render-after-sales-harness.php -> after-sales page -> after-sales-rendered.html
// php render-after-sales-harness.php orders -> orders list page -> orders-rendered.html
// php render-after-sales-harness.php <scenario> -> run one handler scenario, print JSON log
namespace {
define('ABSPATH', __DIR__ . '/');
@@ -54,9 +55,23 @@ function wp_die($m) { throw new Exception('wp_die: ' . $m); }
function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; }
function get_the_title($id) { return 'Product' . $id; }
function is_admin() { return true; }
function paginate_links($args = []) { return ''; }
}
namespace AKSRT\Ledger {
final class AssetAmount {
public static function balance($value): self { return new self(); }
public function decimal(): string { return '0.00'; }
}
}
namespace AKSRT\Payment {
class Gateway {
private static $i;
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get_all(): array { return ['balance' => (object) [], 'points' => (object) []]; }
}
class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
@@ -77,6 +92,16 @@ namespace AKSRT\Payment {
\HarnessLog::add('can_finalize_order_refund', ['id' => $after_sale_id, 'order' => $order_number, 'ticket_status' => is_array($row) ? (string) ($row['status'] ?? '') : null, 'ok' => $ok]);
return $ok;
}
public function find_active(string $order_number): ?array {
$found = null;
foreach (self::$rows as $row) {
if ((string) ($row['order_number'] ?? '') !== $order_number) continue;
if (!in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) continue;
if ($found === null || (int) ($row['id'] ?? 0) > (int) ($found['id'] ?? 0)) $found = $row;
}
\HarnessLog::add('find_active', ['order' => $order_number, 'ticket' => $found === null ? null : (int) ($found['id'] ?? 0)]);
return $found;
}
public function set_status(int $id, string $status, string $note = '') {
\HarnessLog::add('set_status', ['id' => $id, 'status' => $status, 'note' => $note]);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) {
@@ -96,10 +121,13 @@ namespace AKSRT\Payment {
public static array $orders = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); }
public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); }
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
\HarnessLog::add('update_status', ['order' => $order_number, 'status' => $new_status, 'extra' => $extra]);
// Mirrors OrderRepository::update_status(): refund needs allow_refund, an active
// after-sale id, and a valid paid -> refunded transition.
if ($new_status !== 'refunded') return true;
if (empty($extra['allow_refund'])) return false;
$order = self::$orders[$order_number] ?? null;
if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false;
@@ -121,8 +149,42 @@ namespace AKSRT\Core {
public const ORDER_STATUS_PAID = 'paid';
public const ORDER_STATUS_REFUNDED = 'refunded';
public const ORDER_STATUS_CLOSED = 'closed';
public const ORDER_PRODUCT_RESOURCE = 'resource';
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
public static function order_product_type_labels(): array { return ['reading' => '付费阅读']; }
public const ORDER_PRODUCT_READING = 'reading';
public const ORDER_PRODUCT_DOWNLOAD = 'download';
public const ORDER_PRODUCT_IMAGE = 'image';
public const ORDER_PRODUCT_VIDEO = 'video';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
public const ORDER_PRODUCT_POINTS = 'points';
public static function order_product_type_labels(): array {
return [
self::ORDER_PRODUCT_READING => '付费阅读',
self::ORDER_PRODUCT_DOWNLOAD => '付费下载',
self::ORDER_PRODUCT_IMAGE => '付费图片',
self::ORDER_PRODUCT_VIDEO => '付费视频',
self::ORDER_PRODUCT_VIP => '购买会员',
self::ORDER_PRODUCT_RECHARGE => '余额充值',
self::ORDER_PRODUCT_POINTS => '购买积分',
];
}
public static function order_status_labels(): array {
return [
self::ORDER_STATUS_PENDING => '待支付',
self::ORDER_STATUS_PAID => '已支付',
self::ORDER_STATUS_REFUNDED => '已退款',
self::ORDER_STATUS_CLOSED => '已关闭',
];
}
public static function order_transitions(): array {
return [
self::ORDER_STATUS_PENDING => [self::ORDER_STATUS_PAID, self::ORDER_STATUS_CLOSED],
self::ORDER_STATUS_PAID => [self::ORDER_STATUS_REFUNDED],
self::ORDER_STATUS_REFUNDED => [],
self::ORDER_STATUS_CLOSED => [],
];
}
}
}
@@ -151,10 +213,22 @@ namespace {
'created_at' => '2026-10-02 11:00:00', 'reviewed_at' => '2026-10-03 09:00:00'],
];
\AKSRT\Payment\OrderRepository::$orders = [
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'],
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'],
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'],
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '8.00', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-02 09:00:00', 'paid_at' => '2026-10-02 09:05:00'],
'ORD102' => ['order_number' => 'ORD102', 'user_id' => 5, 'product_id' => 11, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '20.00', 'payment_method' => 'balance', 'status' => 'refunded', 'created_at' => '2026-10-03 09:00:00', 'paid_at' => '2026-10-03 09:05:00'],
'ORD103' => ['order_number' => 'ORD103', 'user_id' => 6, 'product_id' => 12, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '5.00', 'payment_method' => 'balance', 'status' => 'pending', 'created_at' => '2026-10-04 09:00:00', 'paid_at' => ''],
'ORD104' => ['order_number' => 'ORD104', 'user_id' => 7, 'product_id' => 13, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '7.00', 'payment_method' => 'balance', 'status' => 'closed', 'created_at' => '2026-10-05 09:00:00', 'paid_at' => '2026-10-05 09:05:00'],
];
if ($scenario === 'orders') {
$_GET = ['page' => 'aksrt_orders'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
ob_start();
$pay->render_orders_page();
file_put_contents(__DIR__ . '/orders-rendered.html', ob_get_clean());
exit(0);
}
if ($scenario !== '') {
// The handler ends in exit(), so the call log is flushed on shutdown.
register_shutdown_function(static function (): void {
@@ -185,13 +259,39 @@ namespace {
case 'bad_status':
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => ''];
break;
case 'refund_paid':
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_no_ticket':
unset(\AKSRT\Payment\AfterSaleService::$rows[5]);
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_inactive_ticket':
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_not_paid':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_already_refunded':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_missing':
$_POST = ['order_number' => 'ORDNOPE'];
break;
default:
fwrite(STDERR, "unknown scenario: {$scenario}\n");
exit(2);
}
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
$pay->handle_after_sale_status();
if (str_starts_with($scenario, 'refund')) {
$pay->handle_refund_order();
} else {
$pay->handle_after_sale_status();
}
// Unreachable: the handler ends in exit().
}
@@ -194,6 +194,29 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', (
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
})
test('paid orders expose a refund button next to the detail link', () => {
const page = read('src/Admin/Native/PayOrders.php')
const service = read('src/Payment/AfterSaleService.php')
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
assert.ok(page.includes("if (\$status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
'refund button only renders for paid orders')
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [\$this, 'handle_refund_order'])"),
'refund action registered')
assert.ok(page.includes('handle_refund_order'), 'refund handler exists')
// 工单号由服务端按订单号反查,客户端只能传订单号
assert.ok(page.includes("AfterSaleService::get_instance()->find_active(\$order_number)"),
'active after-sale application is resolved server-side')
assert.ok(!page.includes('after_sale_id\' => absint'), 'after-sale id is never taken from the request')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
// 各种失败都要给出可操作的提示,而不是静默失败
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) {
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
}
assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed')
})
test('order creation retries only on order-number unique key collisions', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)