MaintenanceService.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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\Database\QueryException;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Schema;
  10. use Illuminate\Support\Facades\Validator;
  11. use Illuminate\Validation\Rule;
  12. use Illuminate\Validation\ValidationException;
  13. class MaintenanceService
  14. {
  15. private ?array $nameCache = null;
  16. public function options(): array
  17. {
  18. $this->ensure();
  19. $options = static fn ($map) => array_map(static fn ($value, $label) => compact('value', 'label'), array_keys($map), array_values($map));
  20. $names = $this->names();
  21. $subcategories = [];
  22. foreach ($names as $value => $label) {
  23. $parent = Policy::parentScope($value);
  24. if ($parent !== null) $subcategories[] = ['value' => $value, 'label' => $label, 'parent' => $parent];
  25. }
  26. return ['scopes' => $options(Policy::scopes()), 'categories' => $options(Policy::CATEGORIES), 'subcategories' => $subcategories,
  27. 'platforms' => $options(['PC' => 'PC', 'H5' => 'H5', 'APP' => 'APP']), 'languages' => $options(Policy::LANGUAGES),
  28. 'states' => $options(Policy::STATES), 'timezones' => DateTimeZone::listIdentifiers(),
  29. 'defaults' => ['enabled' => 0, 'platforms' => ['PC', 'H5', 'APP'], 'timezone' => 'Asia/Shanghai', 'ends_at' => null, 'embeddable' => 1],
  30. 'business_codes' => ['site' => Policy::SITE_CODE, 'game' => Policy::GAME_CODE]];
  31. }
  32. public function listing(array $input): array
  33. {
  34. $params = Validator::make($input, [
  35. 'page' => ['nullable', 'integer', 'min:1', 'max:1000000'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
  36. 'scope_code' => ['nullable', 'string', 'max:64'], 'parent_scope' => ['nullable', Rule::in(array_keys(Policy::CATEGORIES))],
  37. 'platform' => ['nullable', Rule::in(['PC', 'H5', 'APP'])],
  38. 'enabled' => ['nullable', 'integer', 'in:0,1'], 'state' => ['nullable', Rule::in(array_keys(Policy::STATES))],
  39. 'language' => ['nullable', Rule::in(array_keys(Policy::LANGUAGES))],
  40. ])->validate();
  41. if (!empty($params['scope_code']) && !isset(Policy::scopes()[$params['scope_code']]) && Policy::parentScope($params['scope_code']) === null) {
  42. $this->invalid('scope_code', '维护范围不存在');
  43. }
  44. $this->ensure();
  45. $texts = [];
  46. foreach (DB::table('maintenance_translations')->get() as $text) $texts[$text->maintenance_id][$text->language] = ['content' => $text->content];
  47. // 平台、开关、状态、语言:作用在每一行(大类和子项各自判断)
  48. $hasFilter = !empty($params['platform']) || isset($params['enabled']) || !empty($params['state']) || !empty($params['language']);
  49. $matches = function (array $row) use ($params, $texts): bool {
  50. if (!empty($params['platform']) && !in_array($params['platform'], $row['platforms'], true)) return false;
  51. if (isset($params['enabled']) && $row['enabled'] !== (int) $params['enabled']) return false;
  52. if (!empty($params['state']) && $row['state'] !== $params['state']) return false;
  53. if (!empty($params['language']) && !isset($texts[$row['id']][$params['language']])) return false;
  54. return true;
  55. };
  56. $top = [];
  57. $children = [];
  58. foreach (DB::table('maintenance_settings')->orderBy('id')->get() as $record) {
  59. $row = $this->dto((array) $record, $texts[$record->id] ?? []);
  60. if ($row['parent_scope'] === null) $top[$row['scope_code']] = $row;
  61. else $children[$row['parent_scope']][] = $row;
  62. }
  63. // 父级缺失的子项(正常不会出现)当作顶层显示,避免丢失
  64. foreach ($children as $parent => $rows) {
  65. if (!isset($top[$parent])) { foreach ($rows as $row) $top[$row['scope_code']] = $row; unset($children[$parent]); }
  66. }
  67. $scope = $params['scope_code'] ?? '';
  68. $scopeParent = $scope !== '' ? Policy::parentScope($scope) : null;
  69. $rows = [];
  70. foreach ($top as $code => $row) {
  71. if (!empty($params['parent_scope']) && $code !== $params['parent_scope']) continue;
  72. if ($scope !== '' && $code !== ($scopeParent ?? $scope)) continue;
  73. $kids = $children[$code] ?? [];
  74. if ($scopeParent !== null) $kids = array_values(array_filter($kids, fn ($kid) => $kid['scope_code'] === $scope));
  75. $shown = $hasFilter ? array_values(array_filter($kids, $matches)) : $kids;
  76. // 只按子项查询时,大类只是容器,不算命中
  77. $self = $scopeParent === null && (!$hasFilter || $matches($row));
  78. if (!$self && $shown === []) continue;
  79. $rows[] = $row + ['matched' => $self, 'children_count' => count($children[$code] ?? []), 'children' => $shown];
  80. }
  81. // 分页只按顶层(整站、大类);子游戏 / 平台在所属大类的 children 里
  82. return ['total' => count($rows), 'data' => array_slice($rows, ((int) ($params['page'] ?? 1) - 1) * (int) ($params['limit'] ?? 20), (int) ($params['limit'] ?? 20))];
  83. }
  84. public function detail(int $id): array
  85. {
  86. $row = DB::table('maintenance_settings')->where('id', $id)->first();
  87. if (!$row) $this->invalid('id', '维护设置不存在');
  88. return $this->dto((array) $row, $this->texts($id));
  89. }
  90. public function save(array $input): array
  91. {
  92. $params = Validator::make($input, [
  93. 'id' => ['required', 'integer', 'min:1'], 'scope_code' => ['prohibited'],
  94. 'platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
  95. 'platforms.*' => ['required', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
  96. 'enabled' => ['sometimes', 'required', 'integer', 'in:0,1'], 'start_now' => ['sometimes', 'boolean'],
  97. 'starts_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s'], 'ends_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
  98. 'timezone' => ['sometimes', 'required', 'string', Rule::in(DateTimeZone::listIdentifiers())],
  99. 'translations' => ['sometimes', 'array'], 'embeddable' => ['sometimes', 'required', 'integer', 'in:0,1'],
  100. ])->validate();
  101. return DB::transaction(function () use ($params) {
  102. $id = (int) $params['id'];
  103. $found = DB::table('maintenance_settings')->where('id', $id)->lockForUpdate()->first();
  104. if (!$found) $this->invalid('id', '维护设置不存在');
  105. $before = $this->snapshot($id); $row = (array) $found;
  106. // Whether the platform / lottery / sport opens embedded (iframe) on the member site; independent of maintenance.
  107. if (array_key_exists('embeddable', $params)) {
  108. if (Policy::parentScope($row['scope_code']) === null) $this->invalid('embeddable', '只有子类可以设置是否支持内嵌');
  109. $row['embeddable'] = (int) $params['embeddable'];
  110. }
  111. foreach (['enabled', 'timezone', 'platforms'] as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
  112. $row['platforms'] = array_values(array_intersect(['PC', 'H5', 'APP'], Policy::platforms($row)));
  113. if (!empty($params['start_now'])) $row['starts_at'] = $this->now()->format('Y-m-d H:i:s');
  114. elseif (isset($params['starts_at'])) $row['starts_at'] = $this->utc($params['starts_at'], $row['timezone'], 'starts_at');
  115. if (array_key_exists('ends_at', $params)) $row['ends_at'] = $params['ends_at'] === null ? null : $this->utc($params['ends_at'], $row['timezone'], 'ends_at');
  116. if ($row['ends_at'] !== null && $row['ends_at'] <= $row['starts_at']) $this->invalid('ends_at', '结束时间必须晚于开始时间');
  117. $texts = $before['translations'];
  118. foreach ($texts as &$text) unset($text['title']); unset($text);
  119. if (array_key_exists('translations', $params)) {
  120. if (array_diff(array_keys($params['translations']), array_keys(Policy::LANGUAGES))) $this->invalid('translations', '仅支持zh/en/vi');
  121. $texts = [];
  122. foreach ($params['translations'] as $language => $text) {
  123. if (!is_array($text)) $this->invalid('translations', '语言内容必须为对象');
  124. $value = Validator::make($text, ['content' => ['nullable', 'string', 'max:5000']])->validate();
  125. if (trim((string) ($value['content'] ?? '')) !== '') $texts[$language] = ['content' => $value['content']];
  126. }
  127. }
  128. $values = array_intersect_key($row, array_flip(['enabled', 'timezone', 'platforms', 'starts_at', 'ends_at', 'embeddable']));
  129. $values['enabled'] = (int) $values['enabled']; $values['platforms'] = json_encode($values['platforms'], JSON_THROW_ON_ERROR);
  130. $values['updated_at'] = $this->now()->format('Y-m-d H:i:s');
  131. DB::table('maintenance_settings')->where('id', $id)->update($values);
  132. if (array_key_exists('translations', $params)) {
  133. DB::table('maintenance_translations')->where('maintenance_id', $id)->delete();
  134. foreach ($texts as $language => $text) DB::table('maintenance_translations')->insert(['maintenance_id' => $id, 'language' => $language, 'content' => $text['content']]);
  135. }
  136. $after = $this->snapshot($id);
  137. if ($before !== $after) {
  138. OperationAuditService::record('maintenance', $id, 'update', $before, $after, $this->now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
  139. $local = static fn ($at) => $at === null ? null : Carbon::parse($at, 'UTC')->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  140. SiteRealtimeService::changed('maintenance', $id, 'update',
  141. $after['enabled'] ? ['start' => $local($after['starts_at']), 'end' => $local($after['ends_at'])] : []);
  142. }
  143. return $this->detail($id);
  144. });
  145. }
  146. public function status(int $id, int $enabled): array { return $this->save(compact('id', 'enabled')); }
  147. public function logs(array $input): array { return LocalizedAuditService::listing('maintenance', $input); }
  148. /** Telegram 28 bets: the chat game follows pc28_switch (1 = 极速28). Telegram has no PC/H5/APP, so any platform matches. */
  149. public function telegramPc28Failure($language): ?array
  150. {
  151. $language = Policy::language($language) ?? 'zh';
  152. $switch = DB::table('config')->where('field', 'pc28_switch')->value('val');
  153. $subcategory = $switch == 1 ? 'self:lottery:jisu28' : 'self:lottery:pc28';
  154. $now = $this->now()->getTimestamp();
  155. $row = Policy::select($this->runtimeRows($language), null, 'self:lottery', $now, $subcategory);
  156. return $row === null ? null : Policy::failure($row, null, $language, $now);
  157. }
  158. public function runtimeRows(string $language): array
  159. {
  160. return DB::table('maintenance_settings as m')->leftJoin('maintenance_translations as t', function ($join) use ($language) {
  161. $join->on('t.maintenance_id', '=', 'm.id')->where('t.language', $language);
  162. })->where('m.enabled', 1)->select('m.*', 't.content')->get()->map(fn ($row) => (array) $row)->all();
  163. }
  164. private function dto(array $row, array $texts): array
  165. {
  166. $public = Policy::publicRule($row, 'zh'); unset($public['language'], $public['content']);
  167. $parent = Policy::parentScope($row['scope_code']);
  168. return ['id' => (int) $row['id'], 'scope_code' => $row['scope_code'], 'scope_name' => $this->names()[$row['scope_code']] ?? $row['scope_code'],
  169. 'scope_level' => $row['scope_code'] === 'site' ? 'site' : ($parent === null ? 'category' : 'subcategory'), 'parent_scope' => $parent,
  170. 'enabled' => (int) $row['enabled'], 'embeddable' => $parent === null ? null : (int) ($row['embeddable'] ?? 1),
  171. 'state' => Policy::state($row, $this->now()->getTimestamp()), 'translations' => (object) $texts] + $public;
  172. }
  173. private function texts(int $id): array
  174. {
  175. $texts = [];
  176. foreach (DB::table('maintenance_translations')->where('maintenance_id', $id)->orderBy('language')->get() as $text) $texts[$text->language] = ['content' => $text->content];
  177. return $texts;
  178. }
  179. private function snapshot(int $id): array
  180. {
  181. $row = (array) DB::table('maintenance_settings')->where('id', $id)->first();
  182. unset($row['created_at'], $row['updated_at']); $row['enabled'] = (int) $row['enabled']; $row['platforms'] = Policy::platforms($row);
  183. if (Policy::parentScope($row['scope_code']) === null) unset($row['embeddable']); else $row['embeddable'] = (int) ($row['embeddable'] ?? 1);
  184. $row['translations'] = $this->texts($id);
  185. $names = $this->names();
  186. foreach ($row['translations'] as &$text) $text['title'] = $names[$row['scope_code']] ?? $row['scope_code'];
  187. return $row;
  188. }
  189. private function utc(string $value, string $timezone, string $field): string
  190. {
  191. $date = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, new DateTimeZone($timezone));
  192. if (!$date || $date->format('Y-m-d H:i:s') !== $value) $this->invalid($field, '该时区中不存在此时间');
  193. // Repeated local wall times during DST transitions require a different, unambiguous time.
  194. $utc = $date->getTimestamp();
  195. foreach ([-7200, -3600, -1800, 1800, 3600, 7200] as $offset) {
  196. if ($date->setTimestamp($utc + $offset)->format('Y-m-d H:i:s') === $value) $this->invalid($field, '该时区中此时间存在夏令时歧义,请选择其他时间');
  197. }
  198. return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
  199. }
  200. private function names(): array
  201. {
  202. if ($this->nameCache !== null) return $this->nameCache;
  203. $names = Policy::scopes();
  204. if (Schema::hasTable('config')) {
  205. foreach (Policy::configList(DB::table('config')->where('field', 'game_list')->value('val')) as $item) {
  206. $code = Policy::lotterySubcategory($item);
  207. if ($code !== null) $names[$code] = Policy::CATEGORIES['self:lottery'] . ' / ' . $this->label($item['name'] ?? '', $code);
  208. }
  209. foreach (Policy::configList(DB::table('config')->where('field', 'sport_list')->value('val')) as $item) {
  210. $code = Policy::sportSubcategory((string) ($item['code'] ?? ''));
  211. if ($code !== null) $names[$code] = Policy::CATEGORIES['self:sport'] . ' / ' . $this->label($item['name'] ?? '', $code);
  212. }
  213. }
  214. if (!Schema::hasTable('egame_items')) return $this->nameCache = $names;
  215. $rows = DB::table('egame_items')->where('item_type', 'platform')->where('game_type', '>', 0)
  216. ->where('plat_type', '!=', '')->orderBy('id')->get(['plat_type', 'game_type', 'name', 'status']);
  217. foreach ($rows as $row) {
  218. $code = Policy::thirdSubcategory((int) $row->game_type, (string) $row->plat_type);
  219. if ($code === null) continue;
  220. $parent = Policy::parentScope($code);
  221. $plat = strtolower(trim((string) $row->plat_type));
  222. $label = trim((string) $row->name);
  223. $names[$code] = Policy::CATEGORIES[$parent] . ' / ' . ($label === '' ? strtoupper($plat) : $label);
  224. }
  225. return $this->nameCache = $names;
  226. }
  227. private function label($name, string $code): string
  228. {
  229. $name = trim((string) $name);
  230. return $name === '' ? substr($code, strrpos($code, ':') + 1) : $name;
  231. }
  232. /** Missing subcategory rows stay disabled. Opening maintenance still requires an explicit save. */
  233. private function ensure(): void
  234. {
  235. $existing = DB::table('maintenance_settings')->pluck('scope_code')->all();
  236. $now = $this->now()->format('Y-m-d H:i:s');
  237. foreach ($this->names() as $code => $_) {
  238. if (isset(Policy::scopes()[$code]) || in_array($code, $existing, true) || Policy::parentScope($code) === null) continue;
  239. try {
  240. DB::table('maintenance_settings')->insert(['scope_code' => $code, 'platforms' => '["PC","H5","APP"]',
  241. 'enabled' => false, 'starts_at' => $now, 'ends_at' => null, 'timezone' => 'Asia/Shanghai', 'created_at' => $now, 'updated_at' => $now]);
  242. $existing[] = $code;
  243. } catch (QueryException $e) {
  244. // Another admin request inserted the same scope first (integrity constraint violation).
  245. if ((string) $e->getCode() !== '23000') throw $e;
  246. }
  247. }
  248. }
  249. private function now(): Carbon { return Carbon::now('UTC'); }
  250. private function invalid(string $field, string $message): void { throw ValidationException::withMessages([$field => $message]); }
  251. }