Commands.php 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\service;
  4. use app\miniapi\auth\Principal;
  5. use app\miniapi\exception\ApiException;
  6. use app\miniapi\model\MiniUser;
  7. use app\miniapi\model\MiniCommand;
  8. use app\miniapi\model\MiniAudit;
  9. /** A user row serializes retries; business writes and the response commit together. */
  10. final class Commands
  11. {
  12. public static function run(Principal $actor, string $key, string $action, array $payload, callable $operation, callable $authorize): array
  13. {
  14. if (!preg_match('/^[A-Za-z0-9_-]{8,80}$/D', $key)) {
  15. throw new ApiException(42201, 'request_key须为8至80位字母、数字、下划线或短横线', 422);
  16. }
  17. $normalize = function ($value) use (&$normalize) {
  18. if (!is_array($value)) {
  19. return $value;
  20. }
  21. if (!array_is_list($value)) {
  22. ksort($value);
  23. }
  24. return array_map($normalize, $value);
  25. };
  26. $hash = hash('sha256', json_encode($normalize($payload), JSON_THROW_ON_ERROR));
  27. return (new MiniUser())->getConnection()->transaction(function () use ($actor, $key, $action, $operation, $authorize, $hash) {
  28. $user = MiniUser::query()->where('id', $actor->userId)->lockForUpdate()->first();
  29. if (!$user || (int) $user->status !== 1) {
  30. throw new ApiException(40101, '登录状态已失效', 401);
  31. }
  32. // Grant changes use the same user lock. Replays must also retain current permission.
  33. $authorize();
  34. $old = MiniCommand::query()->where([
  35. 'user_id' => $actor->userId,
  36. 'request_key' => $key,
  37. ])->first();
  38. if ($old) {
  39. if ($old->action !== $action || !hash_equals($old->payload_hash, $hash)) {
  40. throw new ApiException(40901, '重复请求的内容发生变化', 409);
  41. }
  42. return json_decode($old->result, true, 512, JSON_THROW_ON_ERROR) + ['replayed' => true];
  43. }
  44. try {
  45. $result = $operation();
  46. } catch (\plugin\saiadmin\exception\ApiException $e) {
  47. throw new ApiException(40902, $e->getMessage(), 409);
  48. }
  49. MiniCommand::query()->insert([
  50. 'user_id' => $actor->userId,
  51. 'request_key' => $key,
  52. 'action' => $action,
  53. 'payload_hash' => $hash,
  54. 'result' => json_encode($result, JSON_THROW_ON_ERROR),
  55. 'created_at' => date('Y-m-d H:i:s'),
  56. ]);
  57. self::audit('mini', (int) $actor->userId, $action, (int) ($result['id'] ?? 0), [
  58. 'request_key' => $key,
  59. 'payload_hash' => $hash,
  60. ]);
  61. return $result;
  62. });
  63. }
  64. public static function audit(string $type, int $actor, string $action, int $resource, array $data): void
  65. {
  66. MiniAudit::query()->insert([
  67. 'actor_type' => $type,
  68. 'actor_id' => $actor,
  69. 'action' => $action,
  70. 'resource_id' => $resource,
  71. 'data' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
  72. 'created_at' => date('Y-m-d H:i:s'),
  73. ]);
  74. }
  75. }