feat(tickets): allow custom status key in status create/edit

- Validate status_key (^[a-z][a-z0-9_]{1,23}$, unique) on create/update
- Renaming a key moves aksrt_ticket.status in the same transaction
- Default statuses remain immutable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-27 23:22:38 +08:00
1 parent 8d871c0828
commit 135fb4eaa4
4 files changed
+134 -7

No files matched your search

+60 -2
View File
@@ -31,9 +31,18 @@ trait TicketConfigRest {
if ($table === '') {
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
}
$key = $this->requested_status_key($request);
if (is_wp_error($key)) {
return $key;
}
if ($key === '') {
$key = TicketStatusService::make_key($name);
} elseif (TicketStatusService::exists($key)) {
return new \WP_Error('aksrt_ticket_status_key_taken', __('该标识已被其他状态使用', 'aksrt'), ['status' => 409]);
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => TicketStatusService::make_key($name),
'status_key' => $key,
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
@@ -60,21 +69,70 @@ trait TicketConfigRest {
if ($name === '' || mb_strlen($name) > 60) {
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
}
$old_key = (string) $row['status_key'];
$key = $this->requested_status_key($request);
if (is_wp_error($key)) {
return $key;
}
if ($key === '') {
$key = $old_key;
}
if ($key !== $old_key && TicketStatusService::exists($key)) {
return new \WP_Error('aksrt_ticket_status_key_taken', __('该标识已被其他状态使用', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
$ticket_table = \AKSRT\Core\DBMigrator::table('ticket');
$rename = $key !== $old_key;
// 改标识需同步工单表 aksrt_ticket.status,两步放进同一事务,避免工单指向不存在的状态。
if ($rename && ($ticket_table === '' || $wpdb->query('START TRANSACTION') === false)) {
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
}
$updated = $wpdb->update($table, [
'status_key' => $key,
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
], ['id' => $id], ['%s', '%s', '%s', '%d', '%s'], ['%d']);
if ($updated !== false && $rename) {
$moved = $wpdb->update($ticket_table, ['status' => $key], ['status' => $old_key], ['%s'], ['%s']);
$updated = $moved === false ? false : $updated;
}
if ($updated === false) {
if ($rename) {
$wpdb->query('ROLLBACK');
}
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
}
if ($rename) {
$wpdb->query('COMMIT');
}
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
/**
* 读取并校验请求里的自定义标识。空串表示「未填写」,由调用方决定生成或沿用。
*
* @return string|\WP_Error
*/
private function requested_status_key(\WP_REST_Request $request) {
$key = strtolower(trim((string) $request->get_param('status_key')));
if ($key === '') {
return '';
}
if (!TicketStatusService::is_valid_key($key)) {
return new \WP_Error(
'aksrt_ticket_status_key',
__('标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2–24 位', 'aksrt'),
['status' => 400]
);
}
return $key;
}
public function delete_status(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
$row = $this->status_row($id);
+2
View File
@@ -59,6 +59,7 @@ final class TicketRestAdmin {
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'status_key' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
],
@@ -71,6 +72,7 @@ final class TicketRestAdmin {
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'status_key' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
],
+13 -3
View File
@@ -141,10 +141,20 @@ final class TicketStatusService {
return $color;
}
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
/**
* 管理员自定义标识的合法形态:小写字母开头,仅含小写字母 / 数字 / 下划线,
* 2–24 位(status_key 列为 VARCHAR(24))。前端 TicketStatusPanel 同步此规则。
*/
public const KEY_PATTERN = '/^[a-z][a-z0-9_]{1,23}$/';
public static function is_valid_key(string $key): bool {
return preg_match(self::KEY_PATTERN, $key) === 1;
}
/** 新建状态且未填标识时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
public static function make_key(string $name): string {
$key = sanitize_key($name);
if ($key === '') {
$key = substr(str_replace('-', '_', sanitize_key($name)), 0, 20);
if (!self::is_valid_key($key)) {
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
}
$base = $key;