feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线

工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。

三处界面由「只显示最后一条」改为回放整条处理链路:

- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
  完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
  意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
  (节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
  一份,两个弹窗共用

清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。

顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
This commit is contained in:
kate522 committed 2026-10-09 13:59:50 +08:00
1 parent 31e6ba00af
commit 26ce9a5904
15 files changed
+644 -57

No files matched your search

+89 -8
View File
@@ -11,6 +11,11 @@ final class AfterSaleService {
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
private const MAX_LOG_ENTRIES = 50;
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
@@ -153,13 +158,15 @@ final class AfterSaleService {
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$now = current_time('mysql');
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason,
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'images' => wp_json_encode($images), 'refund_amount' => $amount,
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
'status' => self::STATUS_PENDING, 'created_at' => $now,
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -212,6 +219,15 @@ final class AfterSaleService {
return $clean;
}
/**
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
*/
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
public function latest_for_order(string $order_number): ?array {
global $wpdb;
@@ -229,6 +245,7 @@ final class AfterSaleService {
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -298,6 +315,10 @@ final class AfterSaleService {
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
@@ -305,12 +326,48 @@ final class AfterSaleService {
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
$data = ['status' => $status];
if ($note !== '') $data['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
return $this->get($id);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5((string) ($row['order_number'] ?? ''));
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
return $this->get($id);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/**
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
*
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
*/
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
if (count($log) > self::MAX_LOG_ENTRIES) {
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
}
$this->update($id, ['handling_log' => $this->encode_log($log)]);
}
/** @return array<string,string> */
private function log_entry(string $status, string $note, string $source, string $created_at): array {
return [
'status' => $status,
'note' => $note,
'source' => $source,
'created_at' => $created_at,
];
}
private function encode_log(array $log): string {
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
@@ -345,6 +402,30 @@ final class AfterSaleService {
: [];
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
return $row;
}
/**
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
*
* @return list<array<string,string>>
*/
private function normalize_log($log): array {
if (!is_array($log)) return [];
$clean = [];
foreach ($log as $entry) {
if (!is_array($entry)) continue;
$status = sanitize_key((string) ($entry['status'] ?? ''));
if ($status === '') continue;
$clean[] = [
'status' => $status,
'note' => (string) ($entry['note'] ?? ''),
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
'created_at' => (string) ($entry['created_at'] ?? ''),
];
}
return $clean;
}
}
+2 -3
View File
@@ -308,9 +308,8 @@ class UserOrders {
'images' => array_values((array) ($latest['images'] ?? [])),
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
'created_at' => (string) ($latest['created_at'] ?? ''),
'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''),
'completed_at' => (string) ($latest['completed_at'] ?? ''),
'admin_note' => (string) ($latest['admin_note'] ?? ''),
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
] : null,
];
}