# 积分消耗与 Token 用量统计系统实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 基于 `mp_user_points_details` 建立按日期/用户/模型/中转站的日粒度预聚合统计,并提供列表、筛选项、CSV 导出三个 API。 **Architecture:** Laravel 定时任务每日聚合明细到 `mp_points_daily_stats` 统计表;`mp_model_relay_map` 维护模型→中转站映射;`PointsStatsService` 提供聚合、查询、导出逻辑,`PointsStatsController` 提供 REST API,按角色(superadmin 全量 / admin 本组织)过滤。 **Tech Stack:** Laravel 8(PHP 7.x)、MySQL 5.7+、PHPUnit 9。 --- ## 文件结构 | 文件 | 职责 | |---|---| | `database/migrations/2026_08_19_000001_create_mp_model_relay_map_table.php` | 创建映射表并初始化数据 | | `database/migrations/2026_08_19_000002_create_mp_points_daily_stats_table.php` | 创建统计表与索引 | | `app/Services/PointsStatsService.php` | 聚合、查询、筛选来源、导出 | | `app/Console/Commands/PointsDailyStatsCommand.php` | 定时聚合命令 | | `app/Http/Controllers/Points/PointsStatsController.php` | API 控制器 | | `routes/api.php` | 注册路由 | | `app/Console/Kernel.php` | 注册调度 | | `tests/Unit/PointsStatsServiceGroupByTest.php` | group_by 解析单测 | --- ### Task 1: 模型→中转站映射表 Migration **Files:** - Create: `database/migrations/2026_08_19_000001_create_mp_model_relay_map_table.php` - [ ] **Step 1: 创建 migration 文件** ```php id(); $table->string('model', 255)->unique()->comment('模型名'); $table->string('relay_name', 100)->comment('中转站名称'); $table->string('remark', 255)->nullable()->comment('备注'); $table->timestamps(); }); foreach ($this->seedData() as [$model, $relay]) { DB::table('mp_model_relay_map')->insert([ 'model' => $model, 'relay_name' => $relay, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } } public function down() { Schema::dropIfExists('mp_model_relay_map'); } } ``` - [ ] **Step 2: 运行迁移** Run: `php artisan migrate` Expected: `CreateMpModelRelayMapTable` 执行成功,无报错。 Verify: `php artisan tinker` 不可用时用下方脚本验证行数: ```bash php -r "require 'D:/wamp64/www/mp_audio_manage/vendor/autoload.php'; \$app=require 'D:/wamp64/www/mp_audio_manage/bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); echo Illuminate\Support\Facades\DB::table('mp_model_relay_map')->count();" ``` Expected: `31` - [ ] **Step 3: 提交** ```bash git add database/migrations/2026_08_19_000001_create_mp_model_relay_map_table.php git commit -m "feat: 新增模型中转站映射表并初始化" ``` --- ### Task 2: 统计表 Migration **Files:** - Create: `database/migrations/2026_08_19_000002_create_mp_points_daily_stats_table.php` - [ ] **Step 1: 创建 migration 文件** ```php id(); $table->date('stat_date')->comment('统计日期(按明细 created_at 归属)'); $table->unsignedBigInteger('uid')->comment('用户ID'); $table->bigInteger('cpid')->default(0)->comment('用户所属组织'); $table->string('model', 255)->comment('模型名'); $table->string('relay', 100)->default('unknown')->comment('中转站名称'); $table->unsignedInteger('call_count')->default(0)->comment('调用次数'); $table->bigInteger('tokens_consumed')->default(0)->comment('token消耗合计'); $table->decimal('points_consumed', 13, 1)->default(0)->comment('积分消耗合计'); $table->timestamps(); $table->unique(['stat_date', 'uid', 'model', 'relay'], 'uk_date_uid_model_relay'); $table->index(['stat_date', 'cpid'], 'idx_stat_date_cpid'); $table->index('relay', 'idx_relay'); $table->index('model', 'idx_model'); }); } public function down() { Schema::dropIfExists('mp_points_daily_stats'); } } ``` - [ ] **Step 2: 运行迁移** Run: `php artisan migrate` Expected: `CreateMpPointsDailyStatsTable` 执行成功。 Verify: ```bash php -r "require 'D:/wamp64/www/mp_audio_manage/vendor/autoload.php'; \$app=require 'D:/wamp64/www/mp_audio_manage/bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); print_r(Illuminate\Support\Facades\Schema::getColumnListing('mp_points_daily_stats'));" ``` Expected: 列清单包含 `stat_date/uid/cpid/model/relay/call_count/tokens_consumed/points_consumed`。 - [ ] **Step 3: 提交** ```bash git add database/migrations/2026_08_19_000002_create_mp_points_daily_stats_table.php git commit -m "feat: 新增积分token日统计表" ``` --- ### Task 3: group_by 解析纯逻辑(TDD) **Files:** - Create: `app/Services/PointsStatsService.php` - Test: `tests/Unit/PointsStatsServiceGroupByTest.php` - [ ] **Step 1: 写失败测试** ```php assertSame(['date', 'uid', 'model', 'relay'], PointsStatsService::parseGroupBy('')); $this->assertSame(['date', 'uid', 'model', 'relay'], PointsStatsService::parseGroupBy(null)); } public function test_valid_combination_preserves_order() { $this->assertSame(['date', 'model'], PointsStatsService::parseGroupBy('date,model')); } public function test_invalid_values_are_filtered() { $this->assertSame(['model'], PointsStatsService::parseGroupBy('model,bad,foo')); } public function test_duplicates_are_removed() { $this->assertSame(['date', 'uid'], PointsStatsService::parseGroupBy('date,uid,date')); } public function test_all_invalid_falls_back_to_default() { $this->assertSame(['date', 'uid', 'model', 'relay'], PointsStatsService::parseGroupBy('bad')); } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `php vendor/bin/phpunit tests/Unit/PointsStatsServiceGroupByTest.php` Expected: FAIL(`Class 'App\Services\PointsStatsService' not found`) - [ ] **Step 3: 创建服务与最小实现** ```php leftJoin('mp_model_relay_map as m', 'm.model', '=', DB::raw($modelExpr)) ->whereIn('d.type', ['chat', 'image', 'video']) ->where('d.created_at', '>=', $start) ->where('d.created_at', '<=', $end) ->selectRaw("DATE(d.created_at) AS stat_date, d.uid, d.cpid, {$modelExpr} AS model, COALESCE(m.relay_name, 'unknown') AS relay, COUNT(*) AS call_count, SUM(d.tokens_consumed) AS tokens_consumed, SUM(d.points_consumed) AS points_consumed") ->groupByRaw("DATE(d.created_at), d.uid, d.cpid, {$modelExpr}, COALESCE(m.relay_name, 'unknown')") ->get() ->map(function ($row) use ($date) { return [ 'stat_date' => $date, 'uid' => (int)$row->uid, 'cpid' => (int)$row->cpid, 'model' => (string)$row->model, 'relay' => (string)$row->relay, 'call_count' => (int)$row->call_count, 'tokens_consumed' => (int)$row->tokens_consumed, 'points_consumed' => (float)$row->points_consumed, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; }) ->all(); DB::transaction(function () use ($date, $rows) { DB::table('mp_points_daily_stats')->where('stat_date', $date)->delete(); if ($rows) { DB::table('mp_points_daily_stats')->insert($rows); } }); return count($rows); } ``` - [ ] **Step 2: 创建命令** ```php option('from'); $to = $this->option('to'); $date = $this->option('date'); if ($from && $to) { $dates = $this->dateRange($from, $to); } elseif ($date) { $dates = [$date]; } elseif ($from || $to) { $this->error('--from 与 --to 必须同时提供'); return 1; } else { $dates = [date('Y-m-d', strtotime('-1 day'))]; } $total = 0; foreach ($dates as $d) { if (!$this->validDate($d)) { $this->error("无效日期: {$d}"); return 1; } $count = $service->statsForDate($d); $total += $count; $this->info("{$d} 写入 {$count} 行"); } $this->info("完成,共写入 {$total} 行"); return 0; } private function validDate(string $date): bool { return (bool)\DateTime::createFromFormat('Y-m-d', $date); } private function dateRange(string $from, string $to): array { $dates = []; $cursor = strtotime($from); $end = strtotime($to); while ($cursor <= $end) { $dates[] = date('Y-m-d', $cursor); $cursor = strtotime('+1 day', $cursor); } return $dates; } } ``` - [ ] **Step 3: Kernel 注册调度** 在 `app/Console/Kernel.php` 的 `schedule()` 末尾追加: ```php // 积分消耗与token用量日统计(聚合前一天明细) $schedule->command('stats:points-daily')->dailyAt('00:10')->withoutOverlapping(); ``` - [ ] **Step 4: 手动验证聚合对账** Run: `php artisan stats:points-daily --date=2026-08-18` Expected: 输出 `2026-08-18 写入 N 行`,N > 0。 对账 SQL(期望 token 合计与明细一致): ```sql SELECT SUM(tokens_consumed), SUM(points_consumed), COUNT(*) FROM mp_points_daily_stats WHERE stat_date = '2026-08-18'; SELECT SUM(tokens_consumed), SUM(points_consumed), COUNT(*) FROM mp_user_points_details WHERE type IN ('chat','image','video') AND created_at >= '2026-08-18 00:00:00' AND created_at <= '2026-08-18 23:59:59'; ``` Expected: 两个查询的三项数值一致。 - [ ] **Step 5: 提交** ```bash git add app/Services/PointsStatsService.php app/Console/Commands/PointsDailyStatsCommand.php app/Console/Kernel.php git commit -m "feat: 积分token日统计聚合命令与调度" ``` --- ### Task 5: 查询与筛选项服务 **Files:** - Modify: `app/Services/PointsStatsService.php` - [ ] **Step 1: 增加查询与筛选来源方法** 在 `statsForDate` 后追加: ```php /** * 当前角色可查看的统计范围:superadmin 全部;admin 本组织;其他抛错。 */ private function assertCanView(): int { $role = (string)\App\Facade\Site::getRole(); if ($role === 'superadmin') { return 0; } if ($role === 'admin') { return (int)\App\Facade\Site::getCpid(); } \App\Libs\Utils::throwError('1005:无权查看统计数据'); } /** * 构建统计查询(含权限过滤与 group_by 聚合)。 */ private function buildStatsQuery(array $params, array $groupBy) { $cpid = $this->assertCanView(); $query = DB::table('mp_points_daily_stats as s') ->leftJoin('mp_manage_users as u', 'u.id', '=', 's.uid'); if ($cpid > 0) { $query->where('s.cpid', $cpid); } $startDate = (string)getProp($params, 'start_date', date('Y-m-d', strtotime('-29 days'))); $endDate = (string)getProp($params, 'end_date', date('Y-m-d')); if ($startDate) { $query->where('s.stat_date', '>=', $startDate); } if ($endDate) { $query->where('s.stat_date', '<=', $endDate); } $uid = (string)getProp($params, 'uid', ''); if ($uid !== '') { $uids = array_values(array_filter(array_map('intval', explode(',', $uid)))); if ($uids) { $query->whereIn('s.uid', $uids); } } $model = (string)getProp($params, 'model', ''); if ($model !== '') { $query->where('s.model', $model); } $relay = (string)getProp($params, 'relay', ''); if ($relay !== '') { $query->where('s.relay', $relay); } $dimToCol = [ 'date' => 's.stat_date', 'uid' => 's.uid', 'model' => 's.model', 'relay' => 's.relay', ]; $cols = []; $groupRaw = []; foreach ($groupBy as $dim) { $cols[] = $dimToCol[$dim] . ' AS dim_' . $dim; $groupRaw[] = $dimToCol[$dim]; } $cols[] = 'SUM(s.call_count) AS call_count'; $cols[] = 'SUM(s.tokens_consumed) AS tokens_consumed'; $cols[] = 'SUM(s.points_consumed) AS points_consumed'; $query->selectRaw(implode(', ', $cols)); if ($groupRaw) { $query->groupByRaw(implode(', ', $groupRaw)); } return [$query, $dimToCol]; } /** * 查询统计列表(分页)。 */ public function getStats(array $params): array { $groupBy = self::parseGroupBy(getProp($params, 'group_by', '')); [$query] = $this->buildStatsQuery($params, $groupBy); $pageSize = (int)getProp($params, 'page_size', 15); if ($pageSize < 1 || $pageSize > 100) { $pageSize = 15; } $rows = $query->paginate($pageSize)->through(function ($row) use ($groupBy) { $item = []; foreach ($groupBy as $dim) { $item[$dim] = $dim === 'uid' ? (int)$row->{'dim_' . $dim} : (string)$row->{'dim_' . $dim}; } $item['call_count'] = (int)$row->call_count; $item['tokens_consumed'] = (int)$row->tokens_consumed; $item['points_consumed'] = (float)$row->points_consumed; return $item; }); // 用户昵称:uid 维度下补充 $list = $rows->items(); if (in_array('uid', $groupBy, true)) { $uids = array_unique(array_column($list, 'uid')); $userMap = DB::table('mp_manage_users')->whereIn('id', $uids) ->pluck('nickname', 'id')->map(function ($v) { return (string)$v; })->all(); foreach ($list as &$item) { $item['nickname'] = $userMap[$item['uid']] ?? ''; } unset($item); } return [ 'summary' => $this->getSummary($params, $groupBy), 'meta' => getMeta($rows), 'list' => $list, ]; } /** * 当前筛选条件下的合计(不按维度分组)。 */ public function getSummary(array $params, array $groupBy): array { [$query] = $this->buildStatsQuery($params, $groupBy); $row = $query->first(); return [ 'call_count' => (int)($row->call_count ?? 0), 'tokens_consumed' => (int)($row->tokens_consumed ?? 0), 'points_consumed' => (float)($row->points_consumed ?? 0), ]; } /** * 筛选项来源:用户/模型/中转站/日期范围。 */ public function getFilters(array $params = []): array { $cpid = $this->assertCanView(); $users = DB::table('mp_manage_users') ->when($cpid > 0, function ($q) use ($cpid) { $q->where('cpid', $cpid); }) ->where('is_enabled', 1) ->select('id', 'nickname', 'account') ->orderBy('id') ->get() ->map(function ($u) { return [ 'id' => (int)$u->id, 'nickname' => (string)$u->nickname, 'account' => (string)$u->account, ]; })->all(); $statsQuery = DB::table('mp_points_daily_stats as s'); if ($cpid > 0) { $statsQuery->where('s.cpid', $cpid); } $models = (clone $statsQuery)->distinct()->pluck('s.model'); $relays = (clone $statsQuery)->distinct()->pluck('s.relay'); $dateRange = (clone $statsQuery)->selectRaw('MIN(stat_date) AS min_date, MAX(stat_date) AS max_date')->first(); return [ 'users' => $users, 'models' => $models, 'relays' => $relays, 'date_range' => [ 'min' => $dateRange->min_date ?? null, 'max' => $dateRange->max_date ?? null, ], ]; } ``` 注意:`Utils::throwError`、`getProp`、`getMeta`、`Site` facade 均为项目现有辅助,无需新增。 - [ ] **Step 2: 提交** ```bash git add app/Services/PointsStatsService.php git commit -m "feat: 统计查询与筛选来源服务" ``` --- ### Task 6: API 控制器与路由 **Files:** - Create: `app/Http/Controllers/Points/PointsStatsController.php` - Modify: `routes/api.php` - [ ] **Step 1: 创建控制器** ```php statsService = $statsService; } /** * 统计列表(日期/用户/模型/中转站筛选,group_by 聚合) */ public function stats(Request $request) { return $this->success($this->statsService->getStats($request->all())); } /** * 筛选项来源 */ public function filters(Request $request) { return $this->success($this->statsService->getFilters($request->all())); } /** * 导出 token 统计 CSV(筛选条件与列表一致) */ public function export(Request $request) { $this->statsService->exportStats($request->all()); } } ``` - [ ] **Step 2: 在 PointsStatsService 增加导出方法** 在 `getFilters` 后追加: ```php /** * 导出 CSV:列随 group_by 动态变化,末尾追加合计行。 */ public function exportStats(array $params): void { $groupBy = self::parseGroupBy(getProp($params, 'group_by', '')); [$query] = $this->buildStatsQuery($params, $groupBy); $rows = $query->get(); $dimLabels = [ 'date' => '统计日期', 'uid' => '用户ID', 'model' => '模型', 'relay' => '中转站', ]; $headers = []; foreach ($groupBy as $dim) { $headers[] = $dimLabels[$dim]; } $headers[] = '调用次数'; $headers[] = 'token消耗'; $headers[] = '积分消耗'; $csvRows = []; foreach ($rows as $row) { $line = []; foreach ($groupBy as $dim) { $line[] = $dim === 'uid' ? (string)(int)$row->{'dim_' . $dim} : (string)$row->{'dim_' . $dim}; } $line[] = (string)(int)$row->call_count; $line[] = (string)(int)$row->tokens_consumed; $line[] = number_format((float)$row->points_consumed, 1, '.', ''); $csvRows[] = $line; } $summary = $this->getSummary($params, $groupBy); $csvRows[] = array_merge( array_fill(0, count($groupBy), '合计'), [ (string)$summary['call_count'], (string)$summary['tokens_consumed'], number_format($summary['points_consumed'], 1, '.', ''), ] ); $startDate = (string)getProp($params, 'start_date', date('Y-m-d', strtotime('-29 days'))); $endDate = (string)getProp($params, 'end_date', date('Y-m-d')); $filename = 'points_stats_' . str_replace('-', '', $startDate) . '_' . str_replace('-', '', $endDate); exportCsv($filename, $headers, $csvRows); } ``` - [ ] **Step 3: 注册路由** 在 `routes/api.php` 的 `points` 路由组内追加(放在 `records` 之后): ```php // 统计系统:列表 / 筛选项 / 导出 Route::get('stats', [\App\Http\Controllers\Points\PointsStatsController::class, 'stats']); Route::get('stats/filters', [\App\Http\Controllers\Points\PointsStatsController::class, 'filters']); Route::get('stats/export', [\App\Http\Controllers\Points\PointsStatsController::class, 'export']); ``` - [ ] **Step 4: 语法检查** Run: `php -l app/Services/PointsStatsService.php && php -l app/Http/Controllers/Points/PointsStatsController.php && php -l routes/api.php` Expected: 三个文件均输出 `No syntax errors detected`。 - [ ] **Step 5: 提交** ```bash git add app/Services/PointsStatsService.php app/Http/Controllers/Points/PointsStatsController.php routes/api.php git commit -m "feat: 统计列表/筛选项/导出API" ``` --- ### Task 7: 端到端验证 **Files:** 无新增 - [ ] **Step 1: 补跑历史统计** Run: `php artisan stats:points-daily --from=2026-08-03 --to=2026-08-19` Expected: 每个日期输出写入行数,无报错。 - [ ] **Step 2: 数据对账** 执行对账 SQL(以 2026-08-18 为例,改为最近有数据的日期): ```sql SELECT SUM(tokens_consumed) AS tok, SUM(points_consumed) AS pts, COUNT(*) AS c FROM mp_points_daily_stats WHERE stat_date = '2026-08-18'; SELECT SUM(tokens_consumed) AS tok, SUM(points_consumed) AS pts, COUNT(*) AS c FROM mp_user_points_details WHERE type IN ('chat','image','video') AND created_at >= '2026-08-18 00:00:00' AND created_at <= '2026-08-18 23:59:59'; ``` Expected: 两行 `tok/pts/c` 完全一致。 - [ ] **Step 3: API 冒烟测试** 用测试账号获取 token 后请求: ```bash curl -H "Authorization: Bearer " "http://localhost/api/points/stats?start_date=2026-08-10&end_date=2026-08-19" curl -H "Authorization: Bearer " "http://localhost/api/points/stats/filters" curl -H "Authorization: Bearer " "http://localhost/api/points/stats/export?start_date=2026-08-10&end_date=2026-08-19" -o stats.csv ``` Expected: 列表返回 `list/meta/summary`;filters 返回四类筛选项;export 下载 CSV 且首行为表头、末行为合计。 - [ ] **Step 4: 运行全部单测** Run: `php vendor/bin/phpunit` Expected: 全部通过(含既有 3 个测试与新增 group_by 测试)。 - [ ] **Step 5: 提交** ```bash git add -A git commit -m "chore: 统计系统端到端验证" ``` --- ## 自审结论 - 规格覆盖:两张表(Task 1/2)、定时任务与口径(Task 4)、列表 API(Task 5/6)、筛选项 API(Task 5/6)、导出 API(Task 6)、权限分级(Task 5)、测试计划(Task 3/7)全部有对应任务。 - 无占位符:所有代码块完整可执行。 - 类型一致:`parseGroupBy` 返回数组;`buildStatsQuery` 返回 `[$query, $dimToCol]`;`getStats/getSummary/exportStats` 均使用同一查询构建,列名 `dim_*` 一致。