Przeglądaj źródła

1.新增token日数据统计2.新增token数据统计列表和导出功能

lh 1 miesiąc temu
rodzic
commit
c2067544b1

+ 89 - 0
app/Console/Commands/PointsDailyStatsCommand.php

@@ -0,0 +1,89 @@
+<?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');
+
+        dLog('command')->info('开始统计积分消耗与token用量', [
+            'date' => $date ?: null,
+            'from' => $from ?: null,
+            'to'   => $to ?: null,
+        ]);
+
+        if ($from && $to) {
+            $dates = $this->dateRange($from, $to);
+        } elseif ($date) {
+            $dates = [$date];
+        } elseif ($from || $to) {
+            dLog('command')->warning('--from 与 --to 必须同时提供', ['from' => $from, 'to' => $to]);
+            $this->error('--from 与 --to 必须同时提供');
+            return 1;
+        } else {
+            $dates = [date('Y-m-d', strtotime('-1 day'))];
+        }
+
+        $total = 0;
+        try {
+            foreach ($dates as $d) {
+                if (!$this->validDate($d)) {
+                    dLog('command')->error('无效日期,终止统计', ['date' => $d]);
+                    $this->error("无效日期: {$d}");
+                    return 1;
+                }
+                $count = $service->statsForDate($d);
+                $total += $count;
+                dLog('command')->info('日期统计完成', ['date' => $d, 'rows' => $count]);
+                $this->info("{$d} 写入 {$count} 行");
+            }
+        } catch (\Throwable $e) {
+            dLog('command')->error('积分token日统计失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            $this->error('统计失败: ' . $e->getMessage());
+            return 1;
+        }
+
+        dLog('command')->info('积分token日统计完成', ['dates' => count($dates), 'total_rows' => $total]);
+        $this->info("完成,共写入 {$total} 行");
+        return 0;
+    }
+
+    private function validDate(string $date): bool
+    {
+        $parts = explode('-', $date);
+        if (count($parts) !== 3) {
+            return false;
+        }
+        [$year, $month, $day] = array_map('intval', $parts);
+        return checkdate($month, $day, $year);
+    }
+
+    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;
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -52,6 +52,9 @@ class Kernel extends ConsoleKernel
 
         // 同步任务中心状态和结果(关联图片/视频任务)
         $schedule->command('taskCenter:sync')->everyMinute()->withoutOverlapping(5);
+
+        // 积分消耗与token用量日统计(聚合前一天明细)
+        $schedule->command('stats:points-daily')->dailyAt('00:10')->withoutOverlapping();
     }
 
     /**

+ 45 - 0
app/Http/Controllers/Points/PointsStatsController.php

@@ -0,0 +1,45 @@
+<?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());
+    }
+}

+ 344 - 0
app/Services/PointsStatsService.php

@@ -0,0 +1,344 @@
+<?php
+
+namespace App\Services;
+
+use App\Facade\Site;
+use App\Libs\Utils;
+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;
+    }
+
+    /**
+     * 聚合指定日期明细到统计表(幂等:先删该日旧数据再批量插入)。
+     *
+     * @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))
+            // cpid 以用户表当前所属组织为准(明细历史数据存在同 uid 不同 cpid 的脏数据)
+            ->leftJoin('mp_manage_users as u', 'u.id', '=', 'd.uid')
+            ->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, 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")
+            ->groupByRaw("DATE(d.created_at), d.uid, {$modelExpr}, COALESCE(m.relay_name, 'unknown'), COALESCE(m.model_type, d.type)")
+            ->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,
+                    'model_type'      => (string)$row->model_type,
+                    '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);
+    }
+
+    /**
+     * 当前角色可查看的统计范围:superadmin 全部;admin 本组织;其他抛错。
+     *
+     * @return int 限定的 cpid(0 表示不限定)
+     */
+    private function assertCanView(): int
+    {
+        $role = (string)Site::getRole();
+        if ($role === 'superadmin') {
+            return 0;
+        }
+        if ($role === 'admin') {
+            return (int)Site::getCpid();
+        }
+        Utils::throwError('1005:无权查看统计数据');
+    }
+
+    /**
+     * 构建统计查询(含权限过滤与 group_by 聚合)。
+     *
+     * @param array $params  筛选参数
+     * @param array $groupBy 聚合维度列表
+     * @return array [查询构建器, 维度=>列映射]
+     */
+    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);
+        }
+
+        $modelType = (string)getProp($params, 'model_type', '');
+        if (in_array($modelType, ['chat', 'image', 'video'], true)) {
+            $query->where('s.model_type', $modelType);
+        }
+
+        $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];
+        }
+        // 按 model 分组时 model_type 有确定值(模型→类型一对一),随分组输出
+        if (in_array('model', $groupBy, true)) {
+            $cols[] = 's.model_type AS dim_model_type';
+            $groupRaw[] = 's.model_type';
+        }
+        $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;
+            $item['model_type'] = (string)($row->dim_model_type ?? '');
+            return $item;
+        });
+
+        $list = $rows->items();
+        if (in_array('uid', $groupBy, true) && $list) {
+            $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),
+            'meta'    => getMeta($rows),
+            'list'    => $list,
+        ];
+    }
+
+    /**
+     * 当前筛选条件下的合计(不按维度分组)。
+     */
+    public function getSummary(array $params): array
+    {
+        [$query] = $this->buildStatsQuery($params, []);
+        $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')->values()->all();
+        $relays = (clone $statsQuery)->distinct()->pluck('s.relay')->values()->all();
+        $modelTypes = (clone $statsQuery)->distinct()->pluck('s.model_type')->values()->all();
+        $dateRange = (clone $statsQuery)->selectRaw('MIN(stat_date) AS min_date, MAX(stat_date) AS max_date')->first();
+
+        return [
+            'users'      => $users,
+            'models'     => $models,
+            'relays'     => $relays,
+            'model_types'=> $modelTypes,
+            'date_range' => [
+                'min' => $dateRange->min_date ?? null,
+                'max' => $dateRange->max_date ?? null,
+            ],
+        ];
+    }
+
+    /**
+     * 导出 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];
+        }
+        if (in_array('model', $groupBy, true)) {
+            $headers[] = '模型类型';
+        }
+        $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};
+            }
+            if (in_array('model', $groupBy, true)) {
+                $line[] = (string)($row->dim_model_type ?? '');
+            }
+            $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);
+        $totalRow = [];
+        foreach ($groupBy as $index => $dim) {
+            $totalRow[] = $index === 0 ? '合计' : '';
+        }
+        if (in_array('model', $groupBy, true)) {
+            $totalRow[] = '';
+        }
+        $csvRows[] = array_merge($totalRow, [
+            (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);
+    }
+}

+ 0 - 216
docs/superpowers/specs/2026-08-19-points-usage-stats-design.md

@@ -1,216 +0,0 @@
-# 积分消耗与 Token 用量统计系统设计
-
-日期:2026-08-19
-状态:待审阅
-
-## 背景与目标
-
-需要基于 `mp_user_points_details` 明细表建立一套使用量统计系统,支持按**日期、用户、模型、中转站**四个维度查看数据,供平台管理员(superadmin)查看全部用户、组织管理员(admin)查看本公司(cpid)用户的统计数据。
-
-核心诉求:
-
-- 定时任务预聚合,前端查询走统计表,保证查询性能稳定。
-- 以 **token 消耗**为核心统计指标,积分消耗作为附带指标。
-- 初始化"模型名 → 中转站"映射表,便于后续人工维护。
-- 提供前端可用的查询 API,支持日期、用户、模型、中转站筛选。
-
-## 相关背景修复
-
-审查中发现并修复了视频积分明细 token 恒为 0 的 bug:
-
-- 根因:`AIVideoGenerationService::markVideoTaskSuccess()` 更新数据库后未同步内存中 `$task->result_json`,导致随后扣费解析 token 时读到旧空值。
-- 已修复 `markVideoTaskSuccess` 同步内存属性,并新增 `points:backfill-video-tokens` 命令回填历史数据(已执行完毕)。
-- 同时补齐了 `analyzeErrorMessage`(错误友好翻译)与 `geminiGenerateText`(潜在未接线路径)两处缺失的 token 记录。
-
-## 数据库设计
-
-### 1. 模型→中转站映射表 `mp_model_relay_map`
-
-用途:维护模型名与中转站的对应关系,统计任务按模型名关联出中转站。
-
-| 字段                    | 类型                         | 说明       |
-| ----------------------- | ---------------------------- | ---------- |
-| id                      | bigint unsigned PK AI        |            |
-| model                   | varchar(255) NOT NULL UNIQUE | 模型名     |
-| relay_name              | varchar(100) NOT NULL        | 中转站名称 |
-| remark                  | varchar(255) NULL            | 备注       |
-| created_at / updated_at | timestamp                    |            |
-
-migration 中初始化以下映射(智能分析所得,后续可人工增改):
-
-| 模型                                                                                                                         | 中转站        |
-| ---------------------------------------------------------------------------------------------------------------------------- | ------------- |
-| deepseek-chat / deepseek-reasoner / deepseek-v4-flash / deepseek-v4-pro                                                      | DeepSeek 官方 |
-| deepseek-v3-2-251201                                                                                                         | 火山方舟      |
-| doubao-seed-2-0-mini/lite/pro-260215                                                                                         | 火山方舟      |
-| doubao-seedream-4-5-251128 / doubao-seedream-5-0-260128 / doubao-seedream-5-0-lite-260128                                    | 火山方舟      |
-| doubao-seedance-1-5-pro-251215 / doubao-seedance-2-0-260128 / doubao-seedance-2-0-fast-260128                                | 火山方舟      |
-| doubao-seedance-2.0                                                                                                          | 百度          |
-| zhizhen-20 / zhizhen-20-fast / zhizhen-20-mini                                                                               | 智帧          |
-| gpt-5.4 / gpt-5.6-terra / gpt-5.6-sol / gpt-5.6-luna / gpt-image-2 / GptImage2                                               | 快快AI        |
-| gemini-3-pro-preview / gemini-3-flash-preview / gemini-3.1-pro-preview / gemini-3.1-flash-image-preview-t / gemini-3.6-flash | 快快AI        |
-| NanoBanana2 / NanoBananaPro                                                                                                  | 速创API       |
-
-未命中映射的模型在统计中归为 `unknown`。
-
-### 2. 统计表 `mp_points_daily_stats`
-
-用途:按 `(stat_date, uid, model, relay)` 维度预聚合的日粒度统计。
-
-| 字段                    | 类型                                    | 说明                               |
-| ----------------------- | --------------------------------------- | ---------------------------------- |
-| id                      | bigint unsigned PK AI                   |                                    |
-| stat_date               | date NOT NULL                           | 统计日期(按明细 created_at 归属) |
-| uid                     | bigint unsigned NOT NULL                | 用户 ID                            |
-| cpid                    | bigint NOT NULL DEFAULT 0               | 用户所属组织(组织管理员过滤用)   |
-| model                   | varchar(255) NOT NULL                   | 模型名                             |
-| relay                   | varchar(100) NOT NULL DEFAULT 'unknown' | 中转站名称                         |
-| call_count              | int unsigned NOT NULL DEFAULT 0         | 调用次数                           |
-| tokens_consumed         | bigint NOT NULL DEFAULT 0               | token 消耗合计(核心指标)         |
-| points_consumed         | decimal(13,1) NOT NULL DEFAULT 0        | 积分消耗合计(附带指标)           |
-| created_at / updated_at | timestamp                               |                                    |
-
-索引:
-
-- 唯一键 `uk_date_uid_model_relay (stat_date, uid, model, relay)`
-- 索引 `idx_stat_date_cpid (stat_date, cpid)`
-- 索引 `idx_relay (relay)`
-- 索引 `idx_model (model)`
-
-## 定时任务
-
-### 命令 `stats:points-daily`
-
-参数:
-
-- `--date=YYYY-MM-DD`:只统计指定日期(默认昨天)
-- `--from=YYYY-MM-DD --to=YYYY-MM-DD`:批量补跑日期区间
-
-统计口径:
-
-- 数据源:`mp_user_points_details`,按 `created_at` 归属到日期。
-- 只统计 `type IN (chat, image, video)`,排除 `system/company`(发放/回收,无模型、无 token)。
-- **不过滤 `test_mode`**,测试用户记录全部纳入(按用户确认)。
-- 模型:优先取 `charge_info->model`,为空回退 `api_type`,再为空归 `unknown`。
-- 中转站:按模型名 LEFT JOIN `mp_model_relay_map` 关联,未命中归 `unknown`。
-- 指标:`call_count = COUNT(*)`,`tokens_consumed = SUM(tokens_consumed)`,`points_consumed = SUM(points_consumed)`。
-
-写入方式:按日期在事务内先删除该日旧数据再批量插入(幂等,可重复执行)。
-
-调度:`Kernel::schedule` 中每天 00:10 执行 `stats:points-daily`,`withoutOverlapping()` 防止并发。
-
-## API 设计
-
-### 1. 查询统计 `GET /api/points/stats`
-
-权限:
-
-- `superadmin`:查询全部用户数据。
-- `admin`:自动限定 `cpid` 为当前登录用户所属组织。
-- 其他角色:返回无权限错误。
-
-请求参数:
-
-| 参数                  | 必填 | 说明                                                                               |
-| --------------------- | ---- | ---------------------------------------------------------------------------------- |
-| start_date / end_date | 否   | 日期范围,默认近 30 天                                                             |
-| uid                   | 否   | 用户 ID,支持逗号分隔多个                                                          |
-| model                 | 否   | 模型名                                                                             |
-| relay                 | 否   | 中转站名称                                                                         |
-| group_by              | 否   | 聚合维度,`date/uid/model/relay` 任意组合(如 `date,model`),默认四维全部分组 |
-| page / page_size      | 否   | 分页,默认 15,最大 100                                                            |
-
-响应(沿用现有 ApiResponse 风格):
-
-```json
-{
-  "code": 0,
-  "data": {
-    "list": [
-      {
-        "stat_date": "2026-08-18",
-        "uid": 142858,
-        "nickname": "用户昵称",
-        "model": "zhizhen-20",
-        "relay": "智帧",
-        "call_count": 10,
-        "tokens_consumed": 1309900,
-        "points_consumed": 840.0
-      }
-    ],
-    "meta": {
-      "current_page": 1,
-      "last_page": 10,
-      "per_page": 15,
-      "total": 145
-    },
-    "summary": {
-      "call_count": 1000,
-      "tokens_consumed": 100000000,
-      "points_consumed": 80000.0
-    }
-  }
-}
-```
-
-### 2. 筛选项来源 `GET /api/points/stats/filters`
-
-返回当前权限范围内的可选值,供前端下拉框使用:
-
-- 用户列表(id + 昵称/账号)
-- 模型列表
-- 中转站列表
-- 可统计日期范围(最早/最晚 stat_date)
-
-### 3. 导出统计 `GET /api/points/stats/export`
-
-导出当前筛选条件下的 token 统计为 CSV 文件,筛选条件与列表接口完全一致。
-
-权限:
-
-- 与列表接口一致:`superadmin` 全量,`admin` 限定本组织,其他角色拒绝。
-
-请求参数(与列表一致,无分页参数):
-
-| 参数 | 必填 | 说明 |
-|---|---|---|
-| start_date / end_date | 否 | 日期范围,默认近 30 天 |
-| uid | 否 | 用户 ID,支持逗号分隔多个 |
-| model | 否 | 模型名 |
-| relay | 否 | 中转站名称 |
-| group_by | 否 | 聚合维度,`date/uid/model/relay` 任意组合,默认四维全部分组 |
-
-输出:
-
-- 复用 `exportCsv` 辅助函数,UTF-8 编码 CSV。
-- 文件名:`points_stats_YYYYMMDD_YYYYMMDD.csv`(按所选日期范围,未指定则为近 30 天范围)。
-- 列结构随 `group_by` 动态变化:先输出选中的维度列(日期/用户ID/用户昵称或账号/模型/中转站),再输出指标列(调用次数、token 消耗、积分消耗);最后追加一行合计(维度列为空,指标列为当前筛选条件下汇总值)。
-
-示例(按 `date,model` 分组):
-
-```csv
-统计日期,模型,调用次数,token消耗,积分消耗
-2026-08-18,zhizhen-20,10,1309900,840.0
-2026-08-18,gpt-5.4,5,120000,50.0
-合计,,,,1429900,890.0
-```
-
-## 目录与文件规划
-
-- `database/migrations/xxxx_create_mp_model_relay_map_table.php`
-- `database/migrations/xxxx_create_mp_points_daily_stats_table.php`
-- `app/Console/Commands/PointsDailyStatsCommand.php`
-- `app/Services/PointsStatsService.php`
-- `app/Http/Controllers/Points/PointsStatsController.php`
-- `app/Transformer/Points/PointsStatsTransformer.php`(如有需要)
-- `routes/api.php` 新增路由
-
-导出接口复用 `app/Libs/Helpers.php` 中的 `exportCsv()`,不新增第三方依赖。
-
-## 测试计划
-
-- 定时任务:手动执行 `--date` 统计结果与明细表 SQL 对账一致。
-- 权限:superadmin 可见全部、admin 仅本组织、其他角色拒绝。
-- 筛选:日期、用户、模型、中转站、group_by 组合。
-- 导出:与列表相同筛选条件下,CSV 行数与列表汇总一致,合计行正确。
-- 幂等:重复执行同一日期统计不产生脏数据。

+ 6 - 0
routes/api.php

@@ -10,6 +10,7 @@ use App\Http\Controllers\Anime\AnimeController;
 use App\Http\Controllers\Canvas\CanvasController;
 use App\Http\Controllers\PromptTemplate\PromptTemplateController;
 use App\Http\Controllers\Points\PointsController;
+use App\Http\Controllers\Points\PointsStatsController;
 use App\Http\Controllers\TaskCenter\TaskCenterController;
 use Illuminate\Support\Facades\Route;
 
@@ -87,6 +88,11 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         // 积分流水
         Route::get('records', [PointsController::class, 'records']);
 
+        // 统计系统:列表 / 筛选项 / 导出
+        Route::get('stats/export', [PointsStatsController::class, 'export']);
+        Route::get('stats/filters', [PointsStatsController::class, 'filters']);
+        Route::get('stats', [PointsStatsController::class, 'stats']);
+
         Route::get('rules', [PointsController::class, 'rules']);            // 获取积分规则
         Route::post('editRule', [PointsController::class, 'editRules']);        // 编辑积分规则
         Route::post('grant', [PointsController::class, 'grantPoints']);      // 发放/回收积分(平台→组织 / 组织→同公司组员,uid支持逗号分隔批量)