Преглед на файлове

1.新增用户积分明细接口的角色权限逻辑,新增用户筛选2.新增导出明细的接口

lh преди 1 месец
родител
ревизия
c3cd40af48

+ 3 - 0
app/Console/Kernel.php

@@ -29,6 +29,9 @@ class Kernel extends ConsoleKernel
      */
     protected function schedule(Schedule $schedule)
     {
+        // 积分消耗与token用量日统计(聚合前一天明细)
+        $schedule->command('test --balance')->dailyAt('00:01')->withoutOverlapping();
+
         // 检查图片生成任务状态
         $schedule->command('AIGeneration:checkImgTasks')->everyMinute();
         

+ 11 - 0
app/Http/Controllers/Points/PointsController.php

@@ -40,6 +40,17 @@ class PointsController extends BaseController
     }
 
     /**
+     * 积分明细导出 CSV(筛选条件与列表一致:uid/type/start_date/end_date)
+     *
+     * @param Request $request
+     * @return void
+     */
+    public function exportRecords(Request $request)
+    {
+        $this->pointsService->exportPointsRecords($request->all());
+    }
+
+    /**
      * 获取系统当前全部积分规则(文生文/图片/视频)
      *
      * 供前端积分规则管理页展示:

+ 4 - 1
app/Libs/Helpers.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 
 use App\Consts\BaseConst;
 use GuzzleHttp\Client;
@@ -2187,6 +2187,9 @@ function exportCsv(string $name, array $headers, array $data = [])
     $handle = fopen('php://output', 'w');
     ob_clean();
 
+    // UTF-8 BOM,避免 Excel 打开 CSV 中文乱码
+    fwrite($handle, "\xEF\xBB\xBF");
+
     fputcsv($handle, $headers);
     if ($data) {
         foreach ($data as $row) {

+ 185 - 43
app/Services/PointsService.php

@@ -8,6 +8,7 @@ use App\Libs\Utils;
 use App\Models\MpGeneratePicTask;
 use App\Models\MpGenerateVideoTask;
 use App\Models\MpUserPointsDetail;
+use App\Transformer\Points\PointsTransformer;
 use Illuminate\Support\Facades\DB;
 
 /**
@@ -503,65 +504,59 @@ class PointsService
      */
     public function getUserPointsRecords(array $params = []): array
     {
-        $uid = (int)getProp($params, 'uid', 0);
-        if (!$uid) {
-            $uid = (int)Site::getUid();
-        }
-
-        $type = getProp($params, 'type', '');
-        $startDate = getProp($params, 'start_date', '');
-        $endDate = getProp($params, 'end_date', '');
         $pageSize = (int)getProp($params, 'page_size', 15);
         if ($pageSize < 1 || $pageSize > 100) {
             $pageSize = 15;
         }
 
-        $query = MpUserPointsDetail::where('uid', $uid);
-        if ($type) {
-            $query->where('type', $type);
-        }
-        if ($startDate) {
-            $query->where('created_at', '>=', $startDate . ' 00:00:00');
-        }
-        if ($endDate) {
-            $query->where('created_at', '<=', $endDate . ' 23:59:59');
-        }
+        // 基础查询(权限 + 筛选条件),records 与 summary 共用,保证汇总随筛选条件变化
+        [$base, $uid] = $this->buildPointsRecordsQuery($params);
 
-        $records = $query->orderBy('created_at', 'desc')
-            ->orderBy('id', 'desc')
+        $records = (clone $base)->orderBy('d.created_at', 'desc')
+            ->orderBy('d.id', 'desc')
             ->paginate($pageSize);
 
-        // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
-        // 排除带 test_mode 标记的测试记录,保证统计口径与用户表实际余额一致
+        // 汇总统计:随筛选条件变化;除 total_points_consumed 外,均排除带 test_mode 标记的测试记录
         $excludeTestRecords = function ($query) {
-            $query->whereNull('charge_info')
-                ->orWhereNull('charge_info->test_mode')
-                ->orWhere('charge_info->test_mode', '!=', 'true');
+            $query->whereNull('d.charge_info')
+                ->orWhereNull('d.charge_info->test_mode')
+                ->orWhere('d.charge_info->test_mode', '!=', 'true');
         };
 
-        $user = DB::table('mp_manage_users')->where('id', $uid)->first();
+        // points_balance 始终为当前登录用户自己的余额,与查询目标无关
+        $loginUid = (int)Site::getUid();
+        $user = $loginUid ? DB::table('mp_manage_users')->where('id', $loginUid)->first() : null;
         $summary = [
             'points_balance' => (int)getProp($user, 'points', 0),
-            'total_points_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
-                ->where('points_consumed', '>', 0)
+            // 消耗积分(不排除测试数据,全量口径)
+            'total_points_consumed' => (int)(clone $base)
+                ->where('d.points_consumed', '>', 0)
+                ->sum('d.points_consumed'),
+            // 消耗积分(排除测试数据)
+            'total_points_consumed_filter_test' => (int)(clone $base)
+                ->where('d.points_consumed', '>', 0)
                 ->where($excludeTestRecords)
-                ->sum('points_consumed'),
-            'total_points_refunded' => (int)MpUserPointsDetail::where('uid', $uid)
-                ->where('points_consumed', '<', 0)
-                ->whereNotIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
+                ->sum('d.points_consumed'),
+            'total_points_refunded' => (int)(clone $base)
+                ->where('d.points_consumed', '<', 0)
+                ->whereNotIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
+                ->sum('d.points_consumed'),
+            'total_points_granted' => (int)abs((clone $base)
+                ->whereIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
+                ->where('d.points_consumed', '<', 0)
+                ->sum('d.points_consumed')),
+            // 消耗 token(不排除测试数据,全量口径)
+            'total_tokens_consumed' => (int)(clone $base)
+                ->sum('d.tokens_consumed'),
+            // 消耗 token(排除测试数据)
+            'total_tokens_consumed_filter_test' => (int)(clone $base)
                 ->where($excludeTestRecords)
-                ->sum('points_consumed'),
-            'total_points_granted' => (int)abs(MpUserPointsDetail::where('uid', $uid)
-                ->whereIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
-                ->where('points_consumed', '<', 0)
+                ->sum('d.tokens_consumed'),
+            'total_count' => (int)(clone $base)
+                ->count('d.id'),
+            'total_count_filter_test' => (int)(clone $base)
                 ->where($excludeTestRecords)
-                ->sum('points_consumed')),
-            'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
-                ->where($excludeTestRecords)
-                ->sum('tokens_consumed'),
-            'total_count' => (int)MpUserPointsDetail::where('uid', $uid)
-                ->where($excludeTestRecords)
-                ->count(),
+                ->count('d.id'),
         ];
 
         return [
@@ -571,6 +566,153 @@ class PointsService
     }
 
     /**
+     * 导出积分明细 CSV(与列表筛选条件一致:uid/type/start_date/end_date)
+     *
+     * 列:类型、模型、积分变动、变动前、变动后、Token、备注、创建时间
+     * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据)
+     *
+     * @param array $params
+     * @return void
+     */
+    public function exportPointsRecords(array $params): void
+    {
+        // 权限与筛选条件与列表一致
+        [$base] = $this->buildPointsRecordsQuery($params);
+        $startDate = (string)getProp($params, 'start_date', '');
+        $endDate = (string)getProp($params, 'end_date', '');
+
+        $rows = (clone $base)->orderBy('d.created_at', 'desc')
+            ->orderBy('d.id', 'desc')
+            ->get();
+
+        $headers = ['账号', '类型', '模型', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
+        $csvRows = [];
+        $totalConsumed = 0;
+        $totalRefunded = 0;
+
+        foreach ($rows as $row) {
+            $chargeInfo = getProp($row, 'charge_info');
+            if (is_string($chargeInfo)) {
+                $chargeInfo = json_decode($chargeInfo, true);
+            }
+            $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
+
+            $typeCode = (string)getProp($row, 'type', '');
+            $pointsConsumed = (float)getProp($row, 'points_consumed', 0);
+            if ($pointsConsumed > 0) {
+                $pointsChange = '-' . $pointsConsumed;
+                $totalConsumed += $pointsConsumed;
+            } elseif ($pointsConsumed < 0) {
+                $pointsChange = '+' . abs($pointsConsumed);
+                // 返还 = 非发放(system/company)的负值记录,即退款类
+                if (!in_array($typeCode, [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY], true)) {
+                    $totalRefunded += abs($pointsConsumed);
+                }
+            } else {
+                $pointsChange = '0';
+            }
+
+            $csvRows[] = [
+                (string)getProp($row, 'account', ''),
+                PointsTransformer::TYPE_LABELS[$typeCode] ?? $typeCode,
+                (string)getProp($chargeInfo, 'model', ''),
+                $pointsChange,
+                (string)(float)getProp($row, 'points_before', 0),
+                (string)(float)getProp($row, 'points_after', 0),
+                (string)(int)getProp($row, 'tokens_consumed', 0),
+                (string)getProp($row, 'remark', ''),
+                (string)transDate(getProp($row, 'created_at')),
+            ];
+        }
+
+        // 总计(最后一行单行展示)
+        $csvRows[] = ['总计', '', '', '', '', '', '', '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded, ''];
+
+        $start = $startDate ?: date('Y-m-d');
+        $end = $endDate ?: date('Y-m-d');
+        exportCsv('points_records_' . str_replace('-', '', $start) . '_' . str_replace('-', '', $end), $headers, $csvRows);
+    }
+
+    /**
+     * 当前角色可查看的积分明细范围:superadmin 全部;admin 本组织;user 仅自己;其他抛错。
+     *
+     * @return array ['cpid' => int 组织限定(0 不限定), 'uid' => int 用户限定(0 不限定)]
+     */
+    private function assertCanViewPoints(): array
+    {
+        $role = (string)Site::getRole();
+        if ($role === 'superadmin') {
+            return ['cpid' => 0, 'uid' => 0];
+        }
+        if ($role === 'admin') {
+            return ['cpid' => (int)Site::getCpid(), 'uid' => 0];
+        }
+        if ($role === 'user') {
+            return ['cpid' => (int)Site::getCpid(), 'uid' => (int)Site::getUid()];
+        }
+        Utils::throwError('1005:无权查看积分明细');
+    }
+
+    /**
+     * 构建积分明细查询(权限范围 + uid/type/日期/nickname 筛选)。
+     *
+     * @param array $params
+     * @return array [查询构建器, 生效的 uid(0 表示多用户视角)]
+     */
+    private function buildPointsRecordsQuery(array $params): array
+    {
+        $scope = $this->assertCanViewPoints();
+
+        $uid = (int)getProp($params, 'uid', 0);
+        $nickname = trim((string)getProp($params, 'nickname', ''));
+
+        // 普通用户仅能查看自己,忽略传入的 uid/nickname 筛选
+        if ($scope['uid'] > 0) {
+            $uid = $scope['uid'];
+        } elseif ($scope['cpid'] > 0 && !$uid && $nickname === '') {
+            // 管理员默认查看自己的记录;传了 uid 或 nickname 时按对应条件查组内
+            $uid = (int)Site::getUid();
+        }
+
+        $base = MpUserPointsDetail::from('mp_user_points_details as d')
+            ->leftJoin('mp_manage_users as u', 'u.id', '=', 'd.uid')
+            ->select('d.*', 'u.account');
+
+        if ($uid) {
+            $base->where('d.uid', $uid);
+        }
+        // 组织范围以用户表当前所属组织为准(与 token 统计口径一致)
+        if ($scope['cpid'] > 0) {
+            $base->where('u.cpid', $scope['cpid']);
+        }
+
+        // 昵称/账号模糊搜索(与 token 统计一致);普通用户仅查自己,忽略 nickname 避免异常传参查不到数据
+        if ($nickname !== '' && $scope['uid'] <= 0) {
+            $base->where(function ($q) use ($nickname) {
+                $q->where('u.nickname', 'like', '%' . $nickname . '%')
+                    ->orWhere('u.account', 'like', '%' . $nickname . '%');
+            });
+        }
+
+        $type = getProp($params, 'type', '');
+        if ($type) {
+            $base->where('d.type', $type);
+        }
+
+        $startDate = getProp($params, 'start_date', '');
+        if ($startDate) {
+            $base->where('d.created_at', '>=', $startDate . ' 00:00:00');
+        }
+
+        $endDate = getProp($params, 'end_date', '');
+        if ($endDate) {
+            $base->where('d.created_at', '<=', $endDate . ' 23:59:59');
+        }
+
+        return [$base, $uid];
+    }
+
+    /**
      * 发放/回收积分(公共方法)
      *
      * 权限规则:

+ 11 - 0
app/Services/PointsStatsService.php

@@ -205,6 +205,11 @@ class PointsStatsService
             $pageSize = 15;
         }
 
+        // 按日期分组时日期倒序展示
+        if (in_array('date', $groupBy, true)) {
+            $query->orderByDesc('s.stat_date');
+        }
+
         $rows = $query->paginate($pageSize)->through(function ($row) use ($groupBy) {
             $item = [];
             foreach ($groupBy as $dim) {
@@ -323,6 +328,12 @@ class PointsStatsService
     {
         $groupBy = self::parseGroupBy(getProp($params, 'group_by', ''));
         [$query] = $this->buildStatsQuery($params, $groupBy);
+
+        // 按日期分组时日期倒序导出,与列表顺序一致
+        if (in_array('date', $groupBy, true)) {
+            $query->orderByDesc('s.stat_date');
+        }
+
         $rows = $query->get();
 
         $dimLabels = [

+ 1 - 0
app/Transformer/Points/PointsTransformer.php

@@ -62,6 +62,7 @@ class PointsTransformer
                 'id' => (int)getProp($item, 'id'),
                 'type' => $type,
                 'type_label' => self::TYPE_LABELS[$type] ?? $type,
+                'account' => (string)getProp($item, 'account', ''),
                 'api_type' => getProp($item, 'api_type'),
                 'task_id' => (int)getProp($item, 'task_id'),
                 'charge_info' => $chargeInfo,

+ 2 - 0
routes/api.php

@@ -87,6 +87,8 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
     Route::group(['prefix' => 'points'], function () {
         // 积分流水
         Route::get('records', [PointsController::class, 'records']);
+        // 积分明细导出(筛选条件与列表一致)
+        Route::get('records/export', [PointsController::class, 'exportRecords']);
 
         // 统计系统:列表 / 筛选项 / 导出
         Route::get('stats/export', [PointsStatsController::class, 'export']);