UserOperation.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. <?php
  2. namespace App\Http\Middleware;
  3. use Closure;
  4. use Illuminate\Http\Request;
  5. use Symfony\Component\HttpFoundation\Response;
  6. use App\Models\UserOperationLog;
  7. use App\Models\UserOperationFrame;
  8. use App\Models\UserOperationDaily;
  9. use App\Services\AuthService;
  10. class UserOperation
  11. {
  12. private const MAX_INTERVAL = 600_000; // 10 min (ms)
  13. private const MIN_INTERVAL = 60_000; // 1 min (ms)
  14. /**
  15. * Handle an incoming request.
  16. *
  17. * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
  18. */
  19. public function handle(Request $request, Closure $next): Response
  20. {
  21. $response = $next($request);
  22. $user = AuthService::current($request);
  23. if (! $user) {
  24. return $response;
  25. }
  26. $segments = explode('/', trim($request->path(), '/'));
  27. if (count($segments) < 3 || $segments[0] !== 'api' || $segments[1] !== 'v2') {
  28. return $response;
  29. }
  30. $method = $request->method();
  31. $newLog = $this->resolveOperation($segments[2], $method, $request);
  32. if (! $newLog) {
  33. return $response;
  34. }
  35. $currTime = (int) round(microtime(true) * 1000);
  36. // 客户端时区(分钟 → 毫秒)
  37. $clientTimezone = (int) ($request->cookie('timezone', 0)) * -60 * 1000;
  38. /**
  39. * =========================
  40. * 1. 操作日志
  41. * =========================
  42. */
  43. UserOperationLog::forceCreate([
  44. 'id' => app('snowflake')->id(),
  45. 'user_id' => $user['user_id'],
  46. 'op_type_id' => $newLog['op_type_id'],
  47. 'op_type' => $newLog['op_type'],
  48. 'content' => $newLog['content'],
  49. 'timezone' => $clientTimezone,
  50. 'create_time' => $currTime,
  51. ]);
  52. /**
  53. * =========================
  54. * 2. 活跃时间 Frame
  55. * =========================
  56. */
  57. $lastFrame = UserOperationFrame::where('user_id', $user['user_id'])
  58. ->latest('updated_at')
  59. ->first();
  60. $isNewFrame = true;
  61. if ($lastFrame) {
  62. $isNewFrame = ($currTime - (int) $lastFrame->op_end) > self::MAX_INTERVAL;
  63. }
  64. if ($isNewFrame) {
  65. UserOperationFrame::forceCreate([
  66. 'id' => app('snowflake')->id(),
  67. 'user_id' => $user['user_id'],
  68. 'op_start' => $currTime - self::MIN_INTERVAL,
  69. 'op_end' => $currTime,
  70. 'duration' => self::MIN_INTERVAL,
  71. 'hit' => 1,
  72. 'timezone' => $clientTimezone,
  73. ]);
  74. $thisActiveTime = self::MIN_INTERVAL;
  75. } else {
  76. $thisActiveTime = $currTime - (int) $lastFrame->op_end;
  77. $lastFrame->forceFill([
  78. 'op_end' => $currTime,
  79. 'duration' => $currTime - (int) $lastFrame->op_start,
  80. 'hit' => $lastFrame->hit + 1,
  81. ])->save();
  82. }
  83. /**
  84. * =========================
  85. * 3. Daily 汇总
  86. * =========================
  87. */
  88. $clientTime = $currTime + $clientTimezone;
  89. $clientDateMs = strtotime(gmdate('Y-m-d', $clientTime / 1000)) * 1000;
  90. $daily = UserOperationDaily::firstOrNew([
  91. 'user_id' => $user['user_id'],
  92. 'date_int' => $clientDateMs,
  93. ]);
  94. if ($daily->exists) {
  95. $daily->increment('duration', $thisActiveTime);
  96. $daily->increment('hit');
  97. } else {
  98. $id = app('snowflake')->id();
  99. $daily->forceFill([
  100. 'id' => $id,
  101. 'duration' => self::MIN_INTERVAL,
  102. 'hit' => 1,
  103. ])->save();
  104. }
  105. return $response;
  106. }
  107. /**
  108. * 根据 API 路径与方法解析操作类型
  109. */
  110. private function resolveOperation(string $resource, string $method, Request $request): ?array
  111. {
  112. return match ($resource) {
  113. 'channel' => match ($method) {
  114. 'POST' => [
  115. 'op_type_id' => 11,
  116. 'op_type' => 'channel_create',
  117. 'content' => $request->input('studio') . '/' . $request->input('name'),
  118. ],
  119. 'PUT' => [
  120. 'op_type_id' => 10,
  121. 'op_type' => 'channel_update',
  122. 'content' => $request->input('name'),
  123. ],
  124. default => null,
  125. },
  126. 'article' => match ($method) {
  127. 'POST' => [
  128. 'op_type_id' => 21,
  129. 'op_type' => 'article_create',
  130. 'content' => $request->input('studio') . '/' . $request->input('title'),
  131. ],
  132. 'PUT' => [
  133. 'op_type_id' => 20,
  134. 'op_type' => 'article_update',
  135. 'content' => $request->input('title'),
  136. ],
  137. default => null,
  138. },
  139. 'dict' => [
  140. 'op_type_id' => 30,
  141. 'op_type' => 'dict_lookup',
  142. 'content' => $request->input('word'),
  143. ],
  144. 'terms' => match ($method) {
  145. 'POST' => [
  146. 'op_type_id' => 42,
  147. 'op_type' => 'term_create',
  148. 'content' => $request->input('word'),
  149. ],
  150. 'PUT' => [
  151. 'op_type_id' => 40,
  152. 'op_type' => 'term_update',
  153. 'content' => $request->input('word'),
  154. ],
  155. default => null,
  156. },
  157. 'sentence' => match ($method) {
  158. 'POST' => [
  159. 'op_type_id' => 71,
  160. 'op_type' => 'sent_create',
  161. 'content' => $request->input('channel'),
  162. ],
  163. 'PUT' => [
  164. 'op_type_id' => 70,
  165. 'op_type' => 'sent_update',
  166. 'content' => $request->input('channel'),
  167. ],
  168. default => null,
  169. },
  170. 'anthology' => match ($method) {
  171. 'POST' => [
  172. 'op_type_id' => 81,
  173. 'op_type' => 'collection_create',
  174. 'content' => $request->input('title'),
  175. ],
  176. 'PUT' => [
  177. 'op_type_id' => 80,
  178. 'op_type' => 'collection_update',
  179. 'content' => $request->input('title'),
  180. ],
  181. default => null,
  182. },
  183. 'wbw' => $method === 'POST'
  184. ? [
  185. 'op_type_id' => 60,
  186. 'op_type' => 'wbw_update',
  187. 'content' => implode('_', [
  188. $request->input('book'),
  189. $request->input('para'),
  190. $request->input('channel_id'),
  191. ]),
  192. ]
  193. : null,
  194. default => null,
  195. };
  196. }
  197. }