MaintenanceService.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. <?php
  2. namespace App\Services;
  3. use App\Support\MaintenancePolicy as Policy;
  4. use Carbon\Carbon;
  5. use DateTimeImmutable;
  6. use DateTimeZone;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Validator;
  9. use Illuminate\Validation\Rule;
  10. use Illuminate\Validation\ValidationException;
  11. class MaintenanceService
  12. {
  13. public function options(): array
  14. {
  15. $options = static fn ($map) => array_map(static fn ($value, $label) => compact('value', 'label'), array_keys($map), array_values($map));
  16. return ['scopes' => $options(Policy::scopes()), 'categories' => $options(Policy::CATEGORIES),
  17. 'platforms' => $options(['PC' => 'PC', 'H5' => 'H5', 'APP' => 'APP']), 'languages' => $options(Policy::LANGUAGES),
  18. 'states' => $options(Policy::STATES), 'timezones' => DateTimeZone::listIdentifiers(),
  19. 'defaults' => ['enabled' => 0, 'platforms' => ['PC', 'H5', 'APP'], 'timezone' => 'Asia/Shanghai', 'ends_at' => null],
  20. 'business_codes' => ['site' => Policy::SITE_CODE, 'game' => Policy::GAME_CODE]];
  21. }
  22. public function listing(array $input): array
  23. {
  24. $params = Validator::make($input, [
  25. 'page' => ['nullable', 'integer', 'min:1', 'max:1000000'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
  26. 'scope_code' => ['nullable', Rule::in(array_keys(Policy::scopes()))], 'platform' => ['nullable', Rule::in(['PC', 'H5', 'APP'])],
  27. 'enabled' => ['nullable', 'integer', 'in:0,1'], 'state' => ['nullable', Rule::in(array_keys(Policy::STATES))],
  28. 'language' => ['nullable', Rule::in(array_keys(Policy::LANGUAGES))],
  29. ])->validate();
  30. $texts = [];
  31. foreach (DB::table('maintenance_translations')->get() as $text) $texts[$text->maintenance_id][$text->language] = ['content' => $text->content];
  32. $rows = [];
  33. foreach (DB::table('maintenance_settings')->orderBy('id')->get() as $record) {
  34. $row = $this->dto((array) $record, $texts[$record->id] ?? []);
  35. if (!empty($params['scope_code']) && $row['scope_code'] !== $params['scope_code']) continue;
  36. if (!empty($params['platform']) && !in_array($params['platform'], $row['platforms'], true)) continue;
  37. if (isset($params['enabled']) && $row['enabled'] !== (int) $params['enabled']) continue;
  38. if (!empty($params['state']) && $row['state'] !== $params['state']) continue;
  39. if (!empty($params['language']) && !isset($texts[$record->id][$params['language']])) continue;
  40. $rows[] = $row;
  41. }
  42. return ['total' => count($rows), 'data' => array_slice($rows, ((int) ($params['page'] ?? 1) - 1) * (int) ($params['limit'] ?? 20), (int) ($params['limit'] ?? 20))];
  43. }
  44. public function detail(int $id): array
  45. {
  46. $row = DB::table('maintenance_settings')->where('id', $id)->first();
  47. if (!$row) $this->invalid('id', '维护设置不存在');
  48. return $this->dto((array) $row, $this->texts($id));
  49. }
  50. public function save(array $input): array
  51. {
  52. $params = Validator::make($input, [
  53. 'id' => ['required', 'integer', 'min:1'], 'scope_code' => ['prohibited'],
  54. 'platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
  55. 'platforms.*' => ['required', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
  56. 'enabled' => ['sometimes', 'required', 'integer', 'in:0,1'], 'start_now' => ['sometimes', 'boolean'],
  57. 'starts_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s'], 'ends_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
  58. 'timezone' => ['sometimes', 'required', 'string', Rule::in(DateTimeZone::listIdentifiers())],
  59. 'translations' => ['sometimes', 'array'],
  60. ])->validate();
  61. return DB::transaction(function () use ($params) {
  62. $id = (int) $params['id'];
  63. $found = DB::table('maintenance_settings')->where('id', $id)->lockForUpdate()->first();
  64. if (!$found) $this->invalid('id', '维护设置不存在');
  65. $before = $this->snapshot($id); $row = (array) $found;
  66. foreach (['enabled', 'timezone', 'platforms'] as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
  67. $row['platforms'] = array_values(array_intersect(['PC', 'H5', 'APP'], Policy::platforms($row)));
  68. if (!empty($params['start_now'])) $row['starts_at'] = $this->now()->format('Y-m-d H:i:s');
  69. elseif (isset($params['starts_at'])) $row['starts_at'] = $this->utc($params['starts_at'], $row['timezone'], 'starts_at');
  70. if (array_key_exists('ends_at', $params)) $row['ends_at'] = $params['ends_at'] === null ? null : $this->utc($params['ends_at'], $row['timezone'], 'ends_at');
  71. if ($row['ends_at'] !== null && $row['ends_at'] <= $row['starts_at']) $this->invalid('ends_at', '结束时间必须晚于开始时间');
  72. $texts = $before['translations'];
  73. foreach ($texts as &$text) unset($text['title']); unset($text);
  74. if (array_key_exists('translations', $params)) {
  75. if (array_diff(array_keys($params['translations']), array_keys(Policy::LANGUAGES))) $this->invalid('translations', '仅支持zh/en/vi');
  76. $texts = [];
  77. foreach ($params['translations'] as $language => $text) {
  78. if (!is_array($text)) $this->invalid('translations', '语言内容必须为对象');
  79. $value = Validator::make($text, ['content' => ['nullable', 'string', 'max:5000']])->validate();
  80. if (trim((string) ($value['content'] ?? '')) !== '') $texts[$language] = ['content' => $value['content']];
  81. }
  82. }
  83. $values = array_intersect_key($row, array_flip(['enabled', 'timezone', 'platforms', 'starts_at', 'ends_at']));
  84. $values['enabled'] = (int) $values['enabled']; $values['platforms'] = json_encode($values['platforms'], JSON_THROW_ON_ERROR);
  85. $values['updated_at'] = $this->now()->format('Y-m-d H:i:s');
  86. DB::table('maintenance_settings')->where('id', $id)->update($values);
  87. if (array_key_exists('translations', $params)) {
  88. DB::table('maintenance_translations')->where('maintenance_id', $id)->delete();
  89. foreach ($texts as $language => $text) DB::table('maintenance_translations')->insert(['maintenance_id' => $id, 'language' => $language, 'content' => $text['content']]);
  90. }
  91. $after = $this->snapshot($id);
  92. if ($before !== $after) {
  93. OperationAuditService::record('maintenance', $id, 'update', $before, $after, $this->now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
  94. $local = static fn ($at) => $at === null ? null : Carbon::parse($at, 'UTC')->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  95. SiteRealtimeService::changed('maintenance', $id, 'update',
  96. $after['enabled'] ? ['start' => $local($after['starts_at']), 'end' => $local($after['ends_at'])] : []);
  97. }
  98. return $this->detail($id);
  99. });
  100. }
  101. public function status(int $id, int $enabled): array { return $this->save(compact('id', 'enabled')); }
  102. public function logs(array $input): array { return LocalizedAuditService::listing('maintenance', $input); }
  103. public function runtimeRows(string $language): array
  104. {
  105. return DB::table('maintenance_settings as m')->leftJoin('maintenance_translations as t', function ($join) use ($language) {
  106. $join->on('t.maintenance_id', '=', 'm.id')->where('t.language', $language);
  107. })->where('m.enabled', 1)->select('m.*', 't.content')->get()->map(fn ($row) => (array) $row)->all();
  108. }
  109. private function dto(array $row, array $texts): array
  110. {
  111. $public = Policy::publicRule($row, 'zh'); unset($public['language'], $public['content']);
  112. return ['id' => (int) $row['id'], 'scope_code' => $row['scope_code'], 'scope_name' => Policy::scopes()[$row['scope_code']],
  113. 'enabled' => (int) $row['enabled'], 'state' => Policy::state($row, $this->now()->getTimestamp()),
  114. 'translations' => (object) $texts] + $public;
  115. }
  116. private function texts(int $id): array
  117. {
  118. $texts = [];
  119. foreach (DB::table('maintenance_translations')->where('maintenance_id', $id)->orderBy('language')->get() as $text) $texts[$text->language] = ['content' => $text->content];
  120. return $texts;
  121. }
  122. private function snapshot(int $id): array
  123. {
  124. $row = (array) DB::table('maintenance_settings')->where('id', $id)->first();
  125. unset($row['created_at'], $row['updated_at']); $row['enabled'] = (int) $row['enabled']; $row['platforms'] = Policy::platforms($row);
  126. $row['translations'] = $this->texts($id);
  127. foreach ($row['translations'] as &$text) $text['title'] = Policy::scopes()[$row['scope_code']];
  128. return $row;
  129. }
  130. private function utc(string $value, string $timezone, string $field): string
  131. {
  132. $date = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, new DateTimeZone($timezone));
  133. if (!$date || $date->format('Y-m-d H:i:s') !== $value) $this->invalid($field, '该时区中不存在此时间');
  134. // Repeated local wall times during DST transitions require a different, unambiguous time.
  135. $utc = $date->getTimestamp();
  136. foreach ([-7200, -3600, -1800, 1800, 3600, 7200] as $offset) {
  137. if ($date->setTimestamp($utc + $offset)->format('Y-m-d H:i:s') === $value) $this->invalid($field, '该时区中此时间存在夏令时歧义,请选择其他时间');
  138. }
  139. return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
  140. }
  141. private function now(): Carbon { return Carbon::now('UTC'); }
  142. private function invalid(string $field, string $message): void { throw ValidationException::withMessages([$field => $message]); }
  143. }