|
|
@@ -0,0 +1,861 @@
|
|
|
+# 积分消耗与 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
|
|
|
+<?php
|
|
|
+
|
|
|
+use Illuminate\Database\Migrations\Migration;
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+
|
|
|
+class CreateMpModelRelayMapTable extends Migration
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * 初始化映射:GPT/Gemini 系列归“快快AI”,NanoBanana 归“速创API”,其余按供应商归类。
|
|
|
+ * 未命中映射的模型统计时归 unknown。
|
|
|
+ */
|
|
|
+ private function seedData(): array
|
|
|
+ {
|
|
|
+ $volc = '火山方舟';
|
|
|
+ return [
|
|
|
+ ['deepseek-chat', 'DeepSeek 官方'],
|
|
|
+ ['deepseek-reasoner', 'DeepSeek 官方'],
|
|
|
+ ['deepseek-v4-flash', 'DeepSeek 官方'],
|
|
|
+ ['deepseek-v4-pro', 'DeepSeek 官方'],
|
|
|
+ ['deepseek-v3-2-251201', $volc],
|
|
|
+ ['doubao-seed-2-0-mini-260215', $volc],
|
|
|
+ ['doubao-seed-2-0-lite-260215', $volc],
|
|
|
+ ['doubao-seed-2-0-pro-260215', $volc],
|
|
|
+ ['doubao-seedream-4-5-251128', $volc],
|
|
|
+ ['doubao-seedream-5-0-260128', $volc],
|
|
|
+ ['doubao-seedream-5-0-lite-260128', $volc],
|
|
|
+ ['doubao-seedance-1-5-pro-251215', $volc],
|
|
|
+ ['doubao-seedance-2-0-260128', $volc],
|
|
|
+ ['doubao-seedance-2-0-fast-260128', $volc],
|
|
|
+ ['doubao-seedance-2.0', '百度'],
|
|
|
+ ['zhizhen-20', '智帧'],
|
|
|
+ ['zhizhen-20-fast', '智帧'],
|
|
|
+ ['zhizhen-20-mini', '智帧'],
|
|
|
+ ['gpt-5.4', '快快AI'],
|
|
|
+ ['gpt-5.6-terra', '快快AI'],
|
|
|
+ ['gpt-5.6-sol', '快快AI'],
|
|
|
+ ['gpt-5.6-luna', '快快AI'],
|
|
|
+ ['gpt-image-2', '快快AI'],
|
|
|
+ ['GptImage2', '快快AI'],
|
|
|
+ ['gemini-3-pro-preview', '快快AI'],
|
|
|
+ ['gemini-3-flash-preview', '快快AI'],
|
|
|
+ ['gemini-3.1-pro-preview', '快快AI'],
|
|
|
+ ['gemini-3.1-flash-image-preview-t', '快快AI'],
|
|
|
+ ['gemini-3.6-flash', '快快AI'],
|
|
|
+ ['NanoBanana2', '速创API'],
|
|
|
+ ['NanoBananaPro', '速创API'],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function up()
|
|
|
+ {
|
|
|
+ Schema::create('mp_model_relay_map', function (Blueprint $table) {
|
|
|
+ $table->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
|
|
|
+<?php
|
|
|
+
|
|
|
+use Illuminate\Database\Migrations\Migration;
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+
|
|
|
+class CreateMpPointsDailyStatsTable extends Migration
|
|
|
+{
|
|
|
+ public function up()
|
|
|
+ {
|
|
|
+ Schema::create('mp_points_daily_stats', function (Blueprint $table) {
|
|
|
+ $table->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
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Tests\Unit;
|
|
|
+
|
|
|
+use App\Services\PointsStatsService;
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
+
|
|
|
+class PointsStatsServiceGroupByTest extends TestCase
|
|
|
+{
|
|
|
+ public function test_default_is_all_dimensions()
|
|
|
+ {
|
|
|
+ $this->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
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+
|
|
|
+class PointsStatsService
|
|
|
+{
|
|
|
+ /** group_by 合法维度 */
|
|
|
+ private const GROUP_BY_DIMS = ['date', 'uid', 'model', 'relay'];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析 group_by 参数为合法维度列表;空/非法时回退全部维度,自动去重保序。
|
|
|
+ */
|
|
|
+ public static function parseGroupBy($groupBy = ''): array
|
|
|
+ {
|
|
|
+ if (!is_string($groupBy) || trim($groupBy) === '') {
|
|
|
+ return self::GROUP_BY_DIMS;
|
|
|
+ }
|
|
|
+ $result = [];
|
|
|
+ foreach (explode(',', $groupBy) as $part) {
|
|
|
+ $part = trim($part);
|
|
|
+ if (in_array($part, self::GROUP_BY_DIMS, true) && !in_array($part, $result, true)) {
|
|
|
+ $result[] = $part;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return $result ?: self::GROUP_BY_DIMS;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
+
|
|
|
+Run: `php vendor/bin/phpunit tests/Unit/PointsStatsServiceGroupByTest.php`
|
|
|
+Expected: `OK (5 tests, 8 assertions)`
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/Services/PointsStatsService.php tests/Unit/PointsStatsServiceGroupByTest.php
|
|
|
+git commit -m "feat: PointsStatsService group_by 解析"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 聚合逻辑与定时任务命令
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `app/Services/PointsStatsService.php`
|
|
|
+- Create: `app/Console/Commands/PointsDailyStatsCommand.php`
|
|
|
+- Modify: `app/Console/Kernel.php`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 PointsStatsService 增加聚合方法**
|
|
|
+
|
|
|
+在 `parseGroupBy` 方法后追加:
|
|
|
+
|
|
|
+```php
|
|
|
+ /**
|
|
|
+ * 聚合指定日期明细到统计表(幂等:先删该日旧数据再批量插入)。
|
|
|
+ *
|
|
|
+ * @param string $date Y-m-d
|
|
|
+ * @return int 写入行数
|
|
|
+ */
|
|
|
+ public function statsForDate(string $date): int
|
|
|
+ {
|
|
|
+ $start = $date . ' 00:00:00';
|
|
|
+ $end = $date . ' 23:59:59';
|
|
|
+
|
|
|
+ // 模型名解析:charge_info.model 优先,空则 api_type,再空 unknown
|
|
|
+ $modelExpr = "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.charge_info, '$.model')), ''), NULLIF(d.api_type, ''), 'unknown')";
|
|
|
+
|
|
|
+ $rows = DB::table('mp_user_points_details as d')
|
|
|
+ ->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
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Console\Commands;
|
|
|
+
|
|
|
+use App\Services\PointsStatsService;
|
|
|
+use Illuminate\Console\Command;
|
|
|
+
|
|
|
+class PointsDailyStatsCommand extends Command
|
|
|
+{
|
|
|
+ protected $signature = 'stats:points-daily
|
|
|
+ {--date= : 统计指定日期 Y-m-d,默认昨天}
|
|
|
+ {--from= : 批量补跑起始日期 Y-m-d}
|
|
|
+ {--to= : 批量补跑结束日期 Y-m-d,需与 --from 同用}';
|
|
|
+
|
|
|
+ protected $description = '按日聚合积分消耗与token用量统计';
|
|
|
+
|
|
|
+ public function handle(PointsStatsService $service)
|
|
|
+ {
|
|
|
+ $from = $this->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
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Http\Controllers\Points;
|
|
|
+
|
|
|
+use App\Libs\ApiResponse;
|
|
|
+use App\Services\PointsStatsService;
|
|
|
+use Illuminate\Http\Request;
|
|
|
+use Illuminate\Routing\Controller as BaseController;
|
|
|
+
|
|
|
+class PointsStatsController extends BaseController
|
|
|
+{
|
|
|
+ use ApiResponse;
|
|
|
+
|
|
|
+ /** @var PointsStatsService */
|
|
|
+ protected $statsService;
|
|
|
+
|
|
|
+ public function __construct(PointsStatsService $statsService)
|
|
|
+ {
|
|
|
+ $this->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 <token>" "http://localhost/api/points/stats?start_date=2026-08-10&end_date=2026-08-19"
|
|
|
+curl -H "Authorization: Bearer <token>" "http://localhost/api/points/stats/filters"
|
|
|
+curl -H "Authorization: Bearer <token>" "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_*` 一致。
|