304 lines
10 KiB
PHP
304 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use App\Models\Auth\User;
|
||
use App\Models\Business\SystemizationTodo;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
class SystemizationTodoAuditLogger
|
||
{
|
||
public const TABLE = 'business_systemization_todo_audit_logs';
|
||
|
||
public const HISTORY_LIMIT = 500;
|
||
|
||
public const ACTION_LABELS = [
|
||
'create' => '新增',
|
||
'update' => '修改',
|
||
'delete' => '删除',
|
||
'import' => '导入',
|
||
];
|
||
|
||
public const FIELD_LABELS = [
|
||
'category' => '类型',
|
||
'section' => '内容',
|
||
'title' => '待办事项',
|
||
'owner' => '总负责人',
|
||
'assignee' => '责任人',
|
||
'start_date' => '开始时间',
|
||
'start_note' => '开始时间备注',
|
||
'due_date' => '完成时间',
|
||
'due_note' => '完成时间备注',
|
||
'status' => '状态',
|
||
'completed_at' => '完成日期',
|
||
'progress' => '完成情况',
|
||
'notes' => '备注',
|
||
'attachment_count' => '完成截图',
|
||
'sort_order' => '排序位置',
|
||
];
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array
|
||
{
|
||
$snapshot = [
|
||
'id' => (int) $todo->id,
|
||
'category' => (string) ($todo->category ?? ''),
|
||
'section' => (string) ($todo->section ?? ''),
|
||
'title' => (string) ($todo->title ?? ''),
|
||
'owner' => (string) ($todo->owner ?? ''),
|
||
'assignee' => (string) ($todo->assignee ?? ''),
|
||
'start_date' => $todo->start_date?->format('Y-m-d') ?? '',
|
||
'start_note' => (string) ($todo->start_note ?? ''),
|
||
'due_date' => $todo->due_date?->format('Y-m-d') ?? '',
|
||
'due_note' => (string) ($todo->due_note ?? ''),
|
||
'status' => $todo->statusLabel(),
|
||
'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '',
|
||
'progress' => (string) ($todo->progress ?? ''),
|
||
'notes' => (string) ($todo->notes ?? ''),
|
||
'sort_order' => (int) $todo->sort_order,
|
||
'source_key' => (string) ($todo->source_key ?? ''),
|
||
];
|
||
|
||
if ($withAttachments) {
|
||
$snapshot['attachment_count'] = (int) $todo->attachments()->count();
|
||
}
|
||
|
||
return $snapshot;
|
||
}
|
||
|
||
public function record(
|
||
string $action,
|
||
int $companyId,
|
||
?int $todoId,
|
||
?array $before,
|
||
?array $after,
|
||
?int $userId = null,
|
||
?string $actorName = null,
|
||
array $meta = [],
|
||
): void {
|
||
$this->recordMany([[
|
||
'company_id' => $companyId,
|
||
'todo_id' => $todoId,
|
||
'action' => $action,
|
||
'before' => $before,
|
||
'after' => $after,
|
||
'user_id' => $userId,
|
||
'actor_name' => $actorName,
|
||
'meta' => $meta,
|
||
]]);
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{
|
||
* company_id:int,
|
||
* todo_id:?int,
|
||
* action:string,
|
||
* before:?array,
|
||
* after:?array,
|
||
* user_id:?int,
|
||
* actor_name?:?string,
|
||
* meta?:array
|
||
* }> $events
|
||
*/
|
||
public function recordMany(array $events): void
|
||
{
|
||
if ($events === []) {
|
||
return;
|
||
}
|
||
if (! $this->tableReady()) {
|
||
throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。');
|
||
}
|
||
|
||
$now = $this->platformNow();
|
||
$actorNames = [];
|
||
$rows = [];
|
||
foreach ($events as $event) {
|
||
$action = (string) ($event['action'] ?? '');
|
||
if (! array_key_exists($action, self::ACTION_LABELS)) {
|
||
throw new RuntimeException('不支持的待办操作记录类型。');
|
||
}
|
||
|
||
$userId = isset($event['user_id']) ? (int) $event['user_id'] : null;
|
||
$actorName = trim((string) ($event['actor_name'] ?? ''));
|
||
if ($actorName === '' && $userId !== null) {
|
||
if (! array_key_exists($userId, $actorNames)) {
|
||
$actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? '');
|
||
}
|
||
$actorName = $actorNames[$userId];
|
||
}
|
||
|
||
$meta = is_array($event['meta'] ?? null) ? $event['meta'] : [];
|
||
$rows[] = [
|
||
'company_id' => max(1, (int) ($event['company_id'] ?? 1)),
|
||
'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null,
|
||
'action' => $action,
|
||
'before_json' => $this->json($event['before'] ?? null),
|
||
'after_json' => $this->json($event['after'] ?? null),
|
||
'actor_user_id' => $userId,
|
||
'actor_name' => mb_substr($actorName, 0, 191),
|
||
'extra_json' => $meta === [] ? null : $this->json($meta),
|
||
'at' => $now->toDateTimeString(),
|
||
'created_at' => $now->toDateTimeString(),
|
||
'updated_at' => $now->toDateTimeString(),
|
||
];
|
||
}
|
||
|
||
DB::table(self::TABLE)->insert($rows);
|
||
$companyIds = array_values(array_unique(array_column($rows, 'company_id')));
|
||
foreach ($companyIds as $companyId) {
|
||
$maxId = (int) DB::table(self::TABLE)
|
||
->where('company_id', $companyId)
|
||
->max('id');
|
||
if ($maxId > self::HISTORY_LIMIT) {
|
||
DB::table(self::TABLE)
|
||
->where('company_id', $companyId)
|
||
->where('id', '<=', $maxId - self::HISTORY_LIMIT)
|
||
->delete();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public function history(
|
||
int $companyId,
|
||
int $limit = self::HISTORY_LIMIT,
|
||
?string $from = null,
|
||
): array
|
||
{
|
||
if (! $this->tableReady()) {
|
||
return [];
|
||
}
|
||
|
||
return DB::table(self::TABLE)
|
||
->where('company_id', $companyId)
|
||
->when($from !== null && $from !== '', fn ($query) => $query->where('at', '>=', $from))
|
||
->orderByDesc('id')
|
||
->limit(max(1, min($limit, self::HISTORY_LIMIT)))
|
||
->get()
|
||
->map(fn (object $row): array => $this->serializeEvent($row))
|
||
->all();
|
||
}
|
||
|
||
public function platformNow(): Carbon
|
||
{
|
||
$timezone = trim((string) config('app.platform_timezone', ''));
|
||
if ($timezone === '') {
|
||
$timezone = (string) config('app.timezone', 'UTC');
|
||
}
|
||
|
||
try {
|
||
return Carbon::now($timezone);
|
||
} catch (Throwable) {
|
||
return Carbon::now((string) config('app.timezone', 'UTC'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function serializeEvent(object $row): array
|
||
{
|
||
$before = $row->before_json ? json_decode((string) $row->before_json, true) : null;
|
||
$after = $row->after_json ? json_decode((string) $row->after_json, true) : null;
|
||
$before = is_array($before) ? $before : null;
|
||
$after = is_array($after) ? $after : null;
|
||
$meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : [];
|
||
$meta = is_array($meta) ? $meta : [];
|
||
$source = $after ?? $before ?? [];
|
||
|
||
return [
|
||
'id' => (int) $row->id,
|
||
'at' => (string) $row->at,
|
||
'action' => (string) $row->action,
|
||
'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action,
|
||
'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id,
|
||
'category' => (string) ($source['category'] ?? ''),
|
||
'section' => (string) ($source['section'] ?? ''),
|
||
'title' => (string) ($source['title'] ?? ''),
|
||
'actor' => (string) ($row->actor_name ?? ''),
|
||
'before' => $before,
|
||
'after' => $after,
|
||
'meta' => $meta,
|
||
'changes' => $this->changes($before, $after),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function changes(?array $before, ?array $after): array
|
||
{
|
||
if ($after === null && $before !== null) {
|
||
return ['删除前:' . $this->itemLabel($before)];
|
||
}
|
||
if ($before === null && $after !== null) {
|
||
return ['新增:' . $this->itemLabel($after)];
|
||
}
|
||
if ($before === null && $after === null) {
|
||
return ['—'];
|
||
}
|
||
|
||
$changes = [];
|
||
foreach (self::FIELD_LABELS as $field => $label) {
|
||
$old = $this->displayValue($field, $before[$field] ?? '');
|
||
$new = $this->displayValue($field, $after[$field] ?? '');
|
||
if ($old !== $new) {
|
||
$changes[] = $label . ':' . ($old !== '' ? $old : '空')
|
||
. ' → ' . ($new !== '' ? $new : '空');
|
||
}
|
||
}
|
||
|
||
return $changes === [] ? ['内容无变化'] : $changes;
|
||
}
|
||
|
||
private function itemLabel(array $row): string
|
||
{
|
||
$parts = array_values(array_filter([
|
||
trim((string) ($row['category'] ?? '')),
|
||
trim((string) ($row['section'] ?? '')),
|
||
trim((string) ($row['title'] ?? '')),
|
||
], static fn (string $value): bool => $value !== ''));
|
||
|
||
return $parts === [] ? '未命名待办' : implode(' · ', $parts);
|
||
}
|
||
|
||
private function displayValue(string $field, mixed $value): string
|
||
{
|
||
if ($field === 'attachment_count') {
|
||
return max(0, (int) $value) . ' 张';
|
||
}
|
||
|
||
return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? '')));
|
||
}
|
||
|
||
private function json(mixed $value): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
|
||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
if ($json === false) {
|
||
throw new RuntimeException('待办操作记录序列化失败。');
|
||
}
|
||
|
||
return $json;
|
||
}
|
||
|
||
private function tableReady(): bool
|
||
{
|
||
try {
|
||
return DB::getSchemaBuilder()->hasTable(self::TABLE);
|
||
} catch (Throwable) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|