AppConfiguration.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. <?php
  2. namespace App\Http\Controllers\admin;
  3. use App\Constants\HttpStatus;
  4. use App\Http\Controllers\Controller;
  5. use App\Models\AppDownloadEvent;
  6. use App\Models\AppIosReviewSetting;
  7. use App\Models\AppPackage;
  8. use App\Models\AppSetting;
  9. use App\Models\OperationAudit;
  10. use App\Services\OperationAuditService;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Validation\Rule;
  13. use Illuminate\Validation\ValidationException;
  14. use Throwable;
  15. class AppConfiguration extends Controller
  16. {
  17. public function display()
  18. {
  19. return $this->run(fn () => AppSetting::query()->first() ?: [
  20. 'id' => null,
  21. 'ios_frontend_visible' => 0,
  22. 'android_frontend_visible' => 0,
  23. ]);
  24. }
  25. public function saveDisplay()
  26. {
  27. return $this->run(function () {
  28. $params = request()->validate([
  29. 'ios_frontend_visible' => ['required', Rule::in([0, 1])],
  30. 'android_frontend_visible' => ['required', Rule::in([0, 1])],
  31. ]);
  32. return DB::transaction(function () use ($params) {
  33. $settingId = AppSetting::query()->value('id');
  34. $setting = $settingId
  35. ? AppSetting::query()->lockForUpdate()->findOrFail($settingId)
  36. : AppSetting::query()->create([]);
  37. $before = $setting->toArray();
  38. $setting->fill($params + OperationAuditService::actor())->save();
  39. OperationAuditService::record('app_display_setting', (int)$setting->id, 'update', $before, $setting);
  40. return $setting->fresh();
  41. });
  42. });
  43. }
  44. public function packages()
  45. {
  46. return $this->run(function () {
  47. $params = request()->validate($this->listRules([
  48. 'platform' => ['nullable', Rule::in(['android', 'ios'])],
  49. 'status' => ['nullable', Rule::in([0, 1])],
  50. ]));
  51. $query = AppPackage::query();
  52. foreach (['platform', 'status'] as $field) {
  53. if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
  54. $query->where($field, $params[$field]);
  55. }
  56. }
  57. return $this->paginate($query->orderByDesc('id'), $params);
  58. });
  59. }
  60. public function savePackage()
  61. {
  62. return $this->run(function () {
  63. $params = request()->validate([
  64. 'id' => ['nullable', 'integer'],
  65. 'platform' => ['required', Rule::in(['android', 'ios'])],
  66. 'version' => ['required', 'string', 'max:50'],
  67. 'package_name' => ['required', 'string', 'max:150'],
  68. 'force_update' => ['required', Rule::in([0, 1])],
  69. 'package_type' => ['required', Rule::in(['android_apk', 'testflight', 'app_store', 'enterprise', 'web'])],
  70. 'download_url' => ['required', 'url', 'max:1000'],
  71. 'update_content' => ['nullable', 'string', 'max:10000'],
  72. 'status' => ['nullable', Rule::in([0, 1])],
  73. ]);
  74. $allowedTypes = $params['platform'] === 'android'
  75. ? ['android_apk', 'web']
  76. : ['testflight', 'app_store', 'enterprise', 'web'];
  77. if (!in_array($params['package_type'], $allowedTypes, true)) {
  78. throw ValidationException::withMessages(['package_type' => '安装包类型与平台不匹配']);
  79. }
  80. return $this->saveModel(AppPackage::class, $params, 'app_package');
  81. });
  82. }
  83. public function packageStatus()
  84. {
  85. return $this->setStatus(AppPackage::class, 'app_package');
  86. }
  87. public function deletePackage()
  88. {
  89. return $this->deleteModel(AppPackage::class, 'app_package');
  90. }
  91. public function downloadStats()
  92. {
  93. return $this->run(function () {
  94. $params = request()->validate([
  95. 'page' => ['nullable', 'integer', 'min:1'],
  96. 'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
  97. 'start_date' => ['nullable', 'date_format:Y-m-d'],
  98. 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  99. ]);
  100. $page = (int)($params['page'] ?? 1);
  101. $limit = (int)($params['limit'] ?? 20);
  102. $endDate = $params['end_date'] ?? date('Y-m-d');
  103. $startDate = $params['start_date'] ?? date('Y-m-d', strtotime($endDate . ' -30 days'));
  104. if (strtotime($startDate) > strtotime($endDate)) {
  105. throw ValidationException::withMessages(['end_date' => '结束日期不能早于开始日期']);
  106. }
  107. if (strtotime($endDate) - strtotime($startDate) > 366 * 86400) {
  108. throw ValidationException::withMessages(['end_date' => '单次查询日期范围不能超过366天']);
  109. }
  110. $base = AppDownloadEvent::query()->whereBetween('occurred_at', [
  111. $startDate . ' 00:00:00', $endDate . ' 23:59:59',
  112. ]);
  113. $clickQuery = (clone $base)->where('event_type', 'click')
  114. ->select(['platform', 'source', 'download_url'])
  115. ->selectRaw('COUNT(*) AS click_count')
  116. ->groupBy(['platform', 'source', 'download_url']);
  117. $clickTotal = DB::query()->fromSub(clone $clickQuery, 'click_stats')->count();
  118. $clicks = $clickQuery->orderBy('platform')->orderBy('source')
  119. ->forPage($page, $limit)->get();
  120. $openQuery = (clone $base)->where('event_type', 'open')
  121. ->select(['platform', 'package_type'])
  122. ->selectRaw('COUNT(*) AS open_count')
  123. ->groupBy(['platform', 'package_type']);
  124. $openTotal = DB::query()->fromSub(clone $openQuery, 'open_stats')->count();
  125. $opens = $openQuery->orderBy('platform')->orderBy('package_type')
  126. ->forPage($page, $limit)->get();
  127. return [
  128. 'start_date' => $startDate,
  129. 'end_date' => $endDate,
  130. 'clicks' => ['total' => $clickTotal, 'data' => $clicks],
  131. 'opens' => ['total' => $openTotal, 'data' => $opens],
  132. ];
  133. });
  134. }
  135. public function iosReviews()
  136. {
  137. return $this->run(function () {
  138. $params = request()->validate($this->listRules([
  139. 'start_date' => ['nullable', 'date_format:Y-m-d'],
  140. 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  141. 'status' => ['nullable', Rule::in([0, 1])],
  142. ]));
  143. $query = AppIosReviewSetting::query();
  144. if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
  145. if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
  146. if (array_key_exists('status', $params) && $params['status'] !== null) $query->where('status', $params['status']);
  147. return $this->paginate($query->orderByDesc('id'), $params);
  148. });
  149. }
  150. public function saveIosReview()
  151. {
  152. return $this->run(function () {
  153. $params = request()->validate([
  154. 'id' => ['nullable', 'integer'],
  155. 'store_version' => ['required', 'string', 'max:50'],
  156. 'operating_version' => ['required', 'string', 'max:50'],
  157. 'review_user_ids' => ['nullable', 'array', 'max:1000'],
  158. 'review_user_ids.*' => ['string', 'max:64'],
  159. 'status' => ['nullable', Rule::in([0, 1])],
  160. ]);
  161. $params['platform'] = 'ios';
  162. $params['review_user_ids'] = array_values(array_unique(array_filter($params['review_user_ids'] ?? [])));
  163. return $this->saveModel(AppIosReviewSetting::class, $params, 'app_ios_review_setting');
  164. });
  165. }
  166. public function iosReviewStatus()
  167. {
  168. return $this->setStatus(AppIosReviewSetting::class, 'app_ios_review_setting');
  169. }
  170. public function deleteIosReview()
  171. {
  172. return $this->deleteModel(AppIosReviewSetting::class, 'app_ios_review_setting');
  173. }
  174. public function logs()
  175. {
  176. return $this->run(function () {
  177. $params = request()->validate($this->listRules([
  178. 'resource_type' => ['required', Rule::in(['app_display_setting', 'app_package', 'app_ios_review_setting'])],
  179. 'resource_id' => ['required', 'integer'],
  180. 'operator_name' => ['nullable', 'string', 'max:100'],
  181. ]));
  182. $query = OperationAudit::query()->where('resource_type', $params['resource_type'])->where('resource_id', $params['resource_id']);
  183. if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . $params['operator_name'] . '%');
  184. return $this->paginate($query->orderByDesc('id'), $params);
  185. });
  186. }
  187. private function saveModel(string $modelClass, array $params, string $resourceType): array
  188. {
  189. return DB::transaction(function () use ($modelClass, $params, $resourceType) {
  190. $id = (int)($params['id'] ?? 0);
  191. unset($params['id']);
  192. $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
  193. $before = $model->exists ? $model->toArray() : [];
  194. $model->fill($params + OperationAuditService::actor())->save();
  195. OperationAuditService::record($resourceType, (int)$model->id, $id ? 'update' : 'create', $before, $model);
  196. return $model->fresh()->toArray();
  197. });
  198. }
  199. private function setStatus(string $modelClass, string $resourceType)
  200. {
  201. return $this->run(function () use ($modelClass, $resourceType) {
  202. $params = request()->validate(['id' => ['required', 'integer'], 'status' => ['required', Rule::in([0, 1])]]);
  203. return DB::transaction(function () use ($modelClass, $resourceType, $params) {
  204. $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
  205. $before = $model->toArray();
  206. $model->fill(['status' => $params['status']] + OperationAuditService::actor())->save();
  207. OperationAuditService::record($resourceType, (int)$model->id, 'status', $before, $model);
  208. return $model->fresh()->toArray();
  209. });
  210. });
  211. }
  212. private function deleteModel(string $modelClass, string $resourceType)
  213. {
  214. return $this->run(function () use ($modelClass, $resourceType) {
  215. $params = request()->validate(['id' => ['required', 'integer']]);
  216. return DB::transaction(function () use ($modelClass, $resourceType, $params) {
  217. $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
  218. $before = $model->toArray();
  219. $model->delete();
  220. OperationAuditService::record($resourceType, (int)$model->id, 'delete', $before, []);
  221. return [];
  222. });
  223. });
  224. }
  225. private function listRules(array $extra): array
  226. {
  227. return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']] + $extra;
  228. }
  229. private function paginate($query, array $params): array
  230. {
  231. $page = (int)($params['page'] ?? 1);
  232. $limit = (int)($params['limit'] ?? 20);
  233. return ['total' => (clone $query)->count(), 'data' => $query->forPage($page, $limit)->get()];
  234. }
  235. private function run(callable $callback)
  236. {
  237. try {
  238. return $this->success($callback());
  239. } catch (ValidationException $e) {
  240. return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
  241. } catch (Throwable $e) {
  242. return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
  243. }
  244. }
  245. }