PointsStatsService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. <?php
  2. namespace App\Services;
  3. use App\Facade\Site;
  4. use App\Libs\Utils;
  5. use Illuminate\Support\Facades\DB;
  6. class PointsStatsService
  7. {
  8. /** group_by 合法维度 */
  9. private const GROUP_BY_DIMS = ['date', 'uid', 'model', 'relay'];
  10. /**
  11. * 解析 group_by 参数为合法维度列表;空/非法时回退全部维度,自动去重保序。
  12. */
  13. public static function parseGroupBy($groupBy = ''): array
  14. {
  15. if (!is_string($groupBy) || trim($groupBy) === '') {
  16. return self::GROUP_BY_DIMS;
  17. }
  18. $result = [];
  19. foreach (explode(',', $groupBy) as $part) {
  20. $part = trim($part);
  21. if (in_array($part, self::GROUP_BY_DIMS, true) && !in_array($part, $result, true)) {
  22. $result[] = $part;
  23. }
  24. }
  25. return $result ?: self::GROUP_BY_DIMS;
  26. }
  27. /**
  28. * 聚合指定日期明细到统计表(幂等:先删该日旧数据再批量插入)。
  29. *
  30. * @param string $date Y-m-d
  31. * @return int 写入行数
  32. */
  33. public function statsForDate(string $date): int
  34. {
  35. $start = $date . ' 00:00:00';
  36. $end = $date . ' 23:59:59';
  37. // 模型名解析:charge_info.model 优先,空则 api_type,再空 unknown
  38. $modelExpr = "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.charge_info, '$.model')), ''), NULLIF(d.api_type, ''), 'unknown')";
  39. $rows = DB::table('mp_user_points_details as d')
  40. ->leftJoin('mp_model_relay_map as m', 'm.model', '=', DB::raw($modelExpr))
  41. // cpid 以用户表当前所属组织为准(明细历史数据存在同 uid 不同 cpid 的脏数据)
  42. ->leftJoin('mp_manage_users as u', 'u.id', '=', 'd.uid')
  43. ->whereIn('d.type', ['chat', 'image', 'video'])
  44. ->where('d.created_at', '>=', $start)
  45. ->where('d.created_at', '<=', $end)
  46. ->selectRaw("DATE(d.created_at) AS stat_date, d.uid, COALESCE(u.cpid, 0) AS cpid, {$modelExpr} AS model, COALESCE(m.relay_name, 'unknown') AS relay, COALESCE(m.model_type, d.type) AS model_type, COUNT(*) AS call_count, SUM(d.tokens_consumed) AS tokens_consumed, SUM(d.points_consumed) AS points_consumed")
  47. ->groupByRaw("DATE(d.created_at), d.uid, {$modelExpr}, COALESCE(m.relay_name, 'unknown'), COALESCE(m.model_type, d.type)")
  48. ->get()
  49. ->map(function ($row) use ($date) {
  50. return [
  51. 'stat_date' => $date,
  52. 'uid' => (int)$row->uid,
  53. 'cpid' => (int)$row->cpid,
  54. 'model' => (string)$row->model,
  55. 'relay' => (string)$row->relay,
  56. 'model_type' => (string)$row->model_type,
  57. 'call_count' => (int)$row->call_count,
  58. 'tokens_consumed' => (int)$row->tokens_consumed,
  59. 'points_consumed' => (float)$row->points_consumed,
  60. 'created_at' => date('Y-m-d H:i:s'),
  61. 'updated_at' => date('Y-m-d H:i:s'),
  62. ];
  63. })
  64. ->all();
  65. DB::transaction(function () use ($date, $rows) {
  66. DB::table('mp_points_daily_stats')->where('stat_date', $date)->delete();
  67. if ($rows) {
  68. DB::table('mp_points_daily_stats')->insert($rows);
  69. }
  70. });
  71. return count($rows);
  72. }
  73. /**
  74. * 当前角色可查看的统计范围:superadmin 全部;admin 本组织;其他抛错。
  75. *
  76. * @return int 限定的 cpid(0 表示不限定)
  77. */
  78. private function assertCanView(): int
  79. {
  80. $role = (string)Site::getRole();
  81. if ($role === 'superadmin') {
  82. return 0;
  83. }
  84. if ($role === 'admin') {
  85. return (int)Site::getCpid();
  86. }
  87. Utils::throwError('1005:无权查看统计数据');
  88. }
  89. /**
  90. * 构建统计查询(含权限过滤与 group_by 聚合)。
  91. *
  92. * @param array $params 筛选参数
  93. * @param array $groupBy 聚合维度列表
  94. * @return array [查询构建器, 维度=>列映射]
  95. */
  96. private function buildStatsQuery(array $params, array $groupBy)
  97. {
  98. $cpid = $this->assertCanView();
  99. $query = DB::table('mp_points_daily_stats as s')
  100. ->leftJoin('mp_manage_users as u', 'u.id', '=', 's.uid');
  101. if ($cpid > 0) {
  102. $query->where('s.cpid', $cpid);
  103. }
  104. $startDate = (string)getProp($params, 'start_date', date('Y-m-d', strtotime('-29 days')));
  105. $endDate = (string)getProp($params, 'end_date', date('Y-m-d'));
  106. if ($startDate) {
  107. $query->where('s.stat_date', '>=', $startDate);
  108. }
  109. if ($endDate) {
  110. $query->where('s.stat_date', '<=', $endDate);
  111. }
  112. $uid = (string)getProp($params, 'uid', '');
  113. if ($uid !== '') {
  114. $uids = array_values(array_filter(array_map('intval', explode(',', $uid))));
  115. if ($uids) {
  116. $query->whereIn('s.uid', $uids);
  117. }
  118. }
  119. $model = (string)getProp($params, 'model', '');
  120. if ($model !== '') {
  121. $query->where('s.model', $model);
  122. }
  123. $relay = (string)getProp($params, 'relay', '');
  124. if ($relay !== '') {
  125. $query->where('s.relay', $relay);
  126. }
  127. $modelType = (string)getProp($params, 'model_type', '');
  128. if (in_array($modelType, ['chat', 'image', 'video'], true)) {
  129. $query->where('s.model_type', $modelType);
  130. }
  131. $dimToCol = [
  132. 'date' => 's.stat_date',
  133. 'uid' => 's.uid',
  134. 'model' => 's.model',
  135. 'relay' => 's.relay',
  136. ];
  137. $cols = [];
  138. $groupRaw = [];
  139. foreach ($groupBy as $dim) {
  140. $cols[] = $dimToCol[$dim] . ' AS dim_' . $dim;
  141. $groupRaw[] = $dimToCol[$dim];
  142. }
  143. // 按 model 分组时 model_type 有确定值(模型→类型一对一),随分组输出
  144. if (in_array('model', $groupBy, true)) {
  145. $cols[] = 's.model_type AS dim_model_type';
  146. $groupRaw[] = 's.model_type';
  147. }
  148. $cols[] = 'SUM(s.call_count) AS call_count';
  149. $cols[] = 'SUM(s.tokens_consumed) AS tokens_consumed';
  150. $cols[] = 'SUM(s.points_consumed) AS points_consumed';
  151. $query->selectRaw(implode(', ', $cols));
  152. if ($groupRaw) {
  153. $query->groupByRaw(implode(', ', $groupRaw));
  154. }
  155. return [$query, $dimToCol];
  156. }
  157. /**
  158. * 查询统计列表(分页)。
  159. */
  160. public function getStats(array $params): array
  161. {
  162. $groupBy = self::parseGroupBy(getProp($params, 'group_by', ''));
  163. [$query] = $this->buildStatsQuery($params, $groupBy);
  164. $pageSize = (int)getProp($params, 'page_size', 15);
  165. if ($pageSize < 1 || $pageSize > 100) {
  166. $pageSize = 15;
  167. }
  168. $rows = $query->paginate($pageSize)->through(function ($row) use ($groupBy) {
  169. $item = [];
  170. foreach ($groupBy as $dim) {
  171. $item[$dim] = $dim === 'uid' ? (int)$row->{'dim_' . $dim} : (string)$row->{'dim_' . $dim};
  172. }
  173. $item['call_count'] = (int)$row->call_count;
  174. $item['tokens_consumed'] = (int)$row->tokens_consumed;
  175. $item['points_consumed'] = (float)$row->points_consumed;
  176. $item['model_type'] = (string)($row->dim_model_type ?? '');
  177. return $item;
  178. });
  179. $list = $rows->items();
  180. if (in_array('uid', $groupBy, true) && $list) {
  181. $uids = array_unique(array_column($list, 'uid'));
  182. $userMap = DB::table('mp_manage_users')->whereIn('id', $uids)
  183. ->pluck('nickname', 'id')->map(function ($v) {
  184. return (string)$v;
  185. })->all();
  186. foreach ($list as &$item) {
  187. $item['nickname'] = $userMap[$item['uid']] ?? '';
  188. }
  189. unset($item);
  190. }
  191. return [
  192. 'summary' => $this->getSummary($params),
  193. 'meta' => getMeta($rows),
  194. 'list' => $list,
  195. ];
  196. }
  197. /**
  198. * 当前筛选条件下的合计(不按维度分组)。
  199. */
  200. public function getSummary(array $params): array
  201. {
  202. [$query] = $this->buildStatsQuery($params, []);
  203. $row = $query->first();
  204. return [
  205. 'call_count' => (int)($row->call_count ?? 0),
  206. 'tokens_consumed' => (int)($row->tokens_consumed ?? 0),
  207. 'points_consumed' => (float)($row->points_consumed ?? 0),
  208. ];
  209. }
  210. /**
  211. * 筛选项来源:用户/模型/中转站/日期范围。
  212. */
  213. public function getFilters(array $params = []): array
  214. {
  215. $cpid = $this->assertCanView();
  216. $users = DB::table('mp_manage_users')
  217. ->when($cpid > 0, function ($q) use ($cpid) {
  218. $q->where('cpid', $cpid);
  219. })
  220. ->where('is_enabled', 1)
  221. ->select('id', 'nickname', 'account')
  222. ->orderBy('id')
  223. ->get()
  224. ->map(function ($u) {
  225. return [
  226. 'id' => (int)$u->id,
  227. 'nickname' => (string)$u->nickname,
  228. 'account' => (string)$u->account,
  229. ];
  230. })->all();
  231. $statsQuery = DB::table('mp_points_daily_stats as s');
  232. if ($cpid > 0) {
  233. $statsQuery->where('s.cpid', $cpid);
  234. }
  235. $models = (clone $statsQuery)->distinct()->pluck('s.model')->values()->all();
  236. $relays = (clone $statsQuery)->distinct()->pluck('s.relay')->values()->all();
  237. $modelTypes = (clone $statsQuery)->distinct()->pluck('s.model_type')->values()->all();
  238. $dateRange = (clone $statsQuery)->selectRaw('MIN(stat_date) AS min_date, MAX(stat_date) AS max_date')->first();
  239. return [
  240. 'users' => $users,
  241. 'models' => $models,
  242. 'relays' => $relays,
  243. 'model_types'=> $modelTypes,
  244. 'date_range' => [
  245. 'min' => $dateRange->min_date ?? null,
  246. 'max' => $dateRange->max_date ?? null,
  247. ],
  248. ];
  249. }
  250. /**
  251. * 导出 CSV:列随 group_by 动态变化,末尾追加合计行。
  252. */
  253. public function exportStats(array $params): void
  254. {
  255. $groupBy = self::parseGroupBy(getProp($params, 'group_by', ''));
  256. [$query] = $this->buildStatsQuery($params, $groupBy);
  257. $rows = $query->get();
  258. $dimLabels = [
  259. 'date' => '统计日期',
  260. 'uid' => '用户ID',
  261. 'model' => '模型',
  262. 'relay' => '中转站',
  263. ];
  264. $headers = [];
  265. foreach ($groupBy as $dim) {
  266. $headers[] = $dimLabels[$dim];
  267. }
  268. if (in_array('model', $groupBy, true)) {
  269. $headers[] = '模型类型';
  270. }
  271. $headers[] = '调用次数';
  272. $headers[] = 'token消耗';
  273. $headers[] = '积分消耗';
  274. $csvRows = [];
  275. foreach ($rows as $row) {
  276. $line = [];
  277. foreach ($groupBy as $dim) {
  278. $line[] = $dim === 'uid' ? (string)(int)$row->{'dim_' . $dim} : (string)$row->{'dim_' . $dim};
  279. }
  280. if (in_array('model', $groupBy, true)) {
  281. $line[] = (string)($row->dim_model_type ?? '');
  282. }
  283. $line[] = (string)(int)$row->call_count;
  284. $line[] = (string)(int)$row->tokens_consumed;
  285. $line[] = number_format((float)$row->points_consumed, 1, '.', '');
  286. $csvRows[] = $line;
  287. }
  288. $summary = $this->getSummary($params);
  289. $totalRow = [];
  290. foreach ($groupBy as $index => $dim) {
  291. $totalRow[] = $index === 0 ? '合计' : '';
  292. }
  293. if (in_array('model', $groupBy, true)) {
  294. $totalRow[] = '';
  295. }
  296. $csvRows[] = array_merge($totalRow, [
  297. (string)$summary['call_count'],
  298. (string)$summary['tokens_consumed'],
  299. number_format($summary['points_consumed'], 1, '.', ''),
  300. ]);
  301. $startDate = (string)getProp($params, 'start_date', date('Y-m-d', strtotime('-29 days')));
  302. $endDate = (string)getProp($params, 'end_date', date('Y-m-d'));
  303. $filename = 'points_stats_' . str_replace('-', '', $startDate) . '_' . str_replace('-', '', $endDate);
  304. exportCsv($filename, $headers, $csvRows);
  305. }
  306. }