فهرست منبع

1.积分明细和导出新增动漫剧集序号的统计
2.动漫对话列表和动漫大纲都增加积分总消耗(整剧+分集)
3.新增脚本统计历史积分明细数据中缺失的剧集序号

lh 4 هفته پیش
والد
کامیت
7d9e8941c3

+ 324 - 0
app/Console/Commands/BackfillPointsEpisodeNumberCommand.php

@@ -0,0 +1,324 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\MpUserPointsDetail;
+use App\Services\PointsService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 回填积分明细的分集序号 episode_number
+ *
+ * 回填范围:
+ *   1. 明细列 episode_number(默认为空的行);
+ *   2. charge_info.episode_number(仅补缺失键,已有值一律保留)。
+ *
+ * 解析口径与实时落库一致(PointsService::resolveScriptContext):
+ *   1. charge_info 显式锚点(episode_number / episode_id / anime_id + episode_number);
+ *   2. charge_info 的分镜锚点(alias_act_id / alias_segment_id)→ mp_episode_segments;
+ *   3. 明细缺 anime_id 时,用明细列 anime_id 兜底;
+ *   4. 图片/视频任务按 task_id 回查任务表(alias_act_id/alias_segment_id,其次任务 charge_info)。
+ */
+class BackfillPointsEpisodeNumberCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'points:backfill-episode-number
+                            {--type= : 只回填指定类型(video/image/chat),默认三种类型全部回填}
+                            {--dry-run : 只统计将回填的记录数,不实际更新}
+                            {--force : 重算已有分集序号的记录(默认只处理 episode_number 为空的记录)}
+                            {--skip-charge-info : 只补明细列,不改写 charge_info}
+                            {--limit= : 最多解析回填的记录数(默认全部;列值补 charge_info 为批量更新,不受此限制)}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '回填积分明细的分集序号 episode_number(明细列 + charge_info 仅补缺失键)';
+
+    /**
+     * 每批处理条数
+     */
+    const CHUNK_SIZE = 500;
+
+    /** @var array 任务表 charge_info 兜底解析缓存(type:task_id => episode_number) */
+    private $taskEpisodeCache = [];
+
+    /**
+     * Execute the console command.
+     *
+     * @param PointsService $pointsService
+     * @return int
+     */
+    public function handle(PointsService $pointsService)
+    {
+        if (!Schema::hasColumn('mp_user_points_details', 'episode_number')) {
+            $this->error('mp_user_points_details 缺少 episode_number 字段,请先执行 php artisan migrate');
+
+            return 1;
+        }
+
+        $dryRun = (bool)$this->option('dry-run');
+        $force = (bool)$this->option('force');
+        $skipChargeInfo = (bool)$this->option('skip-charge-info');
+        $limit = (int)$this->option('limit');
+        if ($limit < 0) {
+            $limit = 0;
+        }
+
+        $type = trim((string)$this->option('type'));
+        $types = in_array($type, ['video', 'image', 'chat'], true) ? [$type] : ['video', 'image', 'chat'];
+
+        // 一:明细列已有值、charge_info 缺键的,直接用列值补 charge_info(仅补缺失键)
+        $chargeInfoFilled = 0;
+        if (!$skipChargeInfo) {
+            $chargeInfoFilled += $this->fillChargeInfoFromColumn($types, $dryRun);
+        }
+
+        // 二:按锚点解析回填明细列,并同步补写 charge_info 缺失键
+        $query = DB::table('mp_user_points_details')
+            ->select('id', 'type', 'task_id', 'anime_id', 'charge_info')
+            ->whereIn('type', $types)
+            ->orderBy('id');
+
+        if (!$force) {
+            $query->whereNull('episode_number');
+        }
+
+        $total = (clone $query)->count();
+        $this->info('待解析明细总数: ' . $total . ($dryRun ? '(dry-run 模式,不更新)' : ''));
+        if ($total > 0) {
+            if ($limit > 0) {
+                $this->info('本次最多解析处理: ' . $limit . ' 条');
+            }
+
+            $scanned = 0;
+            $filled = 0;
+            $skipped = 0;
+            $chargeInfoMissing = 0;
+
+            $query->chunkById(self::CHUNK_SIZE, function ($rows) use ($pointsService, $dryRun, $skipChargeInfo, $limit, &$scanned, &$filled, &$skipped, &$chargeInfoFilled, &$chargeInfoMissing) {
+                /** @var array 分集序号 => 待更新明细ID */
+                $pending = [];
+
+                foreach ($rows as $row) {
+                    if ($limit > 0 && $scanned >= $limit) {
+                        // 达到上限:先跳出明细循环,保证已解析的数据在下方统一落库
+                        break;
+                    }
+                    $scanned++;
+
+                    $chargeInfo = $row->charge_info;
+                    if (is_string($chargeInfo)) {
+                        $chargeInfo = json_decode($chargeInfo, true);
+                    }
+                    $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
+
+                    // 明细列已有动漫锚点时作为兜底(历史行 charge_info 可能缺 anime_id)
+                    if (empty($chargeInfo['anime_id']) && (int)$row->anime_id > 0) {
+                        $chargeInfo['anime_id'] = (int)$row->anime_id;
+                    }
+
+                    $taskId = $row->task_id === null ? null : (int)$row->task_id;
+
+                    $context = $pointsService->resolveScriptContext($chargeInfo, $taskId, (string)$row->type);
+                    $episodeNumber = (int)getProp($context, 'episode_number', 0);
+
+                    // 兜底:图片/视频任务表自己的 charge_info 里可能带锚点
+                    if ($episodeNumber <= 0 && $taskId) {
+                        $episodeNumber = $this->episodeNumberFromTask($pointsService, (string)$row->type, $taskId);
+                    }
+
+                    if ($episodeNumber <= 0) {
+                        $skipped++;
+                        continue;
+                    }
+
+                    $pending[$episodeNumber][] = (int)$row->id;
+
+                    // dry-run 统计:解析出序号的行里,还有多少行 charge_info 缺该键
+                    if (!$skipChargeInfo && empty(getProp($chargeInfo, 'episode_number', ''))) {
+                        $chargeInfoMissing++;
+                    }
+                }
+
+                foreach ($pending as $episodeNumber => $ids) {
+                    $episodeNumber = (int)$episodeNumber;
+                    $filled += count($ids);
+
+                    if ($dryRun) {
+                        continue;
+                    }
+
+                    DB::table('mp_user_points_details')->whereIn('id', $ids)->update([
+                        'episode_number' => $episodeNumber,
+                    ]);
+
+                    // charge_info 仅补缺失键(已有值优先)
+                    if (!$skipChargeInfo) {
+                        $chargeInfoFilled += $this->updateChargeInfoEpisodeNumber($ids, $episodeNumber);
+                    }
+                }
+
+                // 达到处理上限时结束分片遍历
+                return !($limit > 0 && $scanned >= $limit);
+            });
+
+            $this->info('扫描明细: ' . $scanned . ' 条,可回填: ' . $filled . ' 条,无分集线索: ' . $skipped . ' 条');
+        }
+
+        if (!$skipChargeInfo) {
+            if ($dryRun) {
+                $this->info('charge_info 待补 episode_number: ' . ($chargeInfoFilled + $chargeInfoMissing) . ' 条(dry-run 未写入)');
+            } else {
+                $this->info('charge_info 补写 episode_number: ' . $chargeInfoFilled . ' 条');
+            }
+        }
+        $this->info($dryRun ? 'dry-run 结束,未更新任何数据' : '回填完成');
+
+        return 0;
+    }
+
+    /**
+     * 用明细列已有的 episode_number 补齐 charge_info 缺失的键(仅补缺失键)
+     *
+     * @param array $types
+     * @param bool  $dryRun
+     * @return int 影响行数(dry-run 为待补条数)
+     */
+    private function fillChargeInfoFromColumn(array $types, bool $dryRun): int
+    {
+        $condition = 'episode_number is not null'
+            . ' and JSON_CONTAINS_PATH(COALESCE(charge_info, JSON_OBJECT()), \'one\', \'$.episode_number\') = 0';
+
+        $count = DB::table('mp_user_points_details')
+            ->whereIn('type', $types)
+            ->whereRaw($condition)
+            ->count();
+
+        if ($count <= 0) {
+            $this->info('charge_info 待补 episode_number: 0 条');
+
+            return 0;
+        }
+
+        if ($dryRun) {
+            $this->info('charge_info 待补 episode_number: ' . $count . ' 条(dry-run 未写入)');
+
+            return $count;
+        }
+
+        // 分批更新,避免一次性锁定过多行
+        $affected = 0;
+        DB::table('mp_user_points_details')
+            ->select('id')
+            ->whereIn('type', $types)
+            ->whereRaw($condition)
+            ->orderBy('id')
+            ->chunkById(self::CHUNK_SIZE, function ($rows) use (&$affected) {
+                $ids = [];
+                foreach ($rows as $row) {
+                    $ids[] = (int)$row->id;
+                }
+
+                $affected += $this->fillChargeInfoFromColumnByIds($ids);
+            });
+
+        $this->info('charge_info 补写 episode_number: ' . $affected . ' 条');
+
+        return $affected;
+    }
+
+    /**
+     * 按明细ID批量用列值补 charge_info 缺失的 episode_number 键(仅补缺失键)
+     *
+     * @param array $ids
+     * @return int 影响行数
+     */
+    private function fillChargeInfoFromColumnByIds(array $ids): int
+    {
+        if (empty($ids)) {
+            return 0;
+        }
+
+        $sql = 'UPDATE mp_user_points_details'
+            . ' SET charge_info = JSON_SET(COALESCE(charge_info, JSON_OBJECT()), \'$.episode_number\', episode_number)'
+            . ' WHERE JSON_CONTAINS_PATH(COALESCE(charge_info, JSON_OBJECT()), \'one\', \'$.episode_number\') = 0'
+            . ' AND id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
+
+        return DB::update($sql, array_map('intval', $ids));
+    }
+
+    /**
+     * 补写指定明细的 charge_info.episode_number(仅补缺失键,已有值保留)
+     *
+     * @param array $ids
+     * @param int   $episodeNumber
+     * @return int 影响行数
+     */
+    private function updateChargeInfoEpisodeNumber(array $ids, int $episodeNumber): int
+    {
+        if ($episodeNumber <= 0 || empty($ids)) {
+            return 0;
+        }
+
+        $sql = 'UPDATE mp_user_points_details'
+            . ' SET charge_info = JSON_SET(COALESCE(charge_info, JSON_OBJECT()), \'$.episode_number\', ?)'
+            . ' WHERE JSON_CONTAINS_PATH(COALESCE(charge_info, JSON_OBJECT()), \'one\', \'$.episode_number\') = 0'
+            . ' AND id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
+
+        return DB::update($sql, array_merge([$episodeNumber], array_map('intval', $ids)));
+    }
+
+    /**
+     * 从图片/视频任务自身解析分集序号(任务 charge_info 兜底)
+     *
+     * @param PointsService $pointsService
+     * @param string $type
+     * @param int $taskId
+     * @return int
+     */
+    private function episodeNumberFromTask(PointsService $pointsService, string $type, int $taskId): int
+    {
+        $table = '';
+        if ($type === MpUserPointsDetail::TYPE_VIDEO) {
+            $table = 'mp_generate_video_tasks';
+        } elseif ($type === MpUserPointsDetail::TYPE_IMAGE) {
+            $table = 'mp_generate_pic_tasks';
+        } else {
+            return 0;
+        }
+
+        $cacheKey = $type . ':' . $taskId;
+        if (array_key_exists($cacheKey, $this->taskEpisodeCache)) {
+            return $this->taskEpisodeCache[$cacheKey];
+        }
+
+        $episodeNumber = 0;
+        try {
+            $chargeInfo = DB::table($table)->where('id', $taskId)->value('charge_info');
+            if (is_string($chargeInfo)) {
+                $chargeInfo = json_decode($chargeInfo, true);
+            }
+            if (is_array($chargeInfo)) {
+                $context = $pointsService->resolveScriptContext($chargeInfo, null, $type);
+                $episodeNumber = (int)getProp($context, 'episode_number', 0);
+            }
+        } catch (\Throwable $e) {
+            dLog('points')->warning('任务锚点回溯失败(不影响主流程): ' . $e->getMessage(), [
+                'type' => $type,
+                'task_id' => $taskId,
+            ]);
+        }
+
+        $this->taskEpisodeCache[$cacheKey] = $episodeNumber;
+
+        return $episodeNumber;
+    }
+}

+ 25 - 17
app/Libs/ScriptAnchor.php

@@ -10,9 +10,9 @@ use Illuminate\Support\Facades\DB;
  *
  * 统一从任务/计费上下文推导「动漫 → 剧集 → 剧幕/分镜 → 剧本」锚点,用于:
  * - 任务创建时写入 charge_info(AIVideoGenerationService / AIImageGenerationService),
- *   只写入 ID 锚点:anime_id / episode_id / act_id(= alias_act_id)/ segment_id(= alias_segment_id)/ script_id,
- *   剧集序号、剧幕序号、分镜序号等展示字段不入 charge_info;
- * - 积分明细落库时写入 anime_id / anime_name / script_id / script_name(PointsService)。
+ *   写入锚点:anime_id / episode_id / episode_number / alias_act_id / alias_segment_id / script_id,
+ *   其中剧幕/分镜以 alias_ 前缀记录,其余展示字段不入 charge_info;
+ * - 积分明细落库时写入 anime_id / anime_name / episode_number / script_id / script_name(PointsService)。
  *
  * 上下文键名兼容两类写法:
  * - 标准锚点:anime_id / episode_id / episode_number / act_id / segment_id / script_id / group_id
@@ -61,18 +61,19 @@ class ScriptAnchor
     private static $taskAnchorCache = [];
 
     /**
-     * 空锚点(仅记录归属所需的 ID 锚点,编号等展示字段不入 charge_info)
+     * 空锚点(归属锚点:动漫 / 剧集 / 分集序号 / 剧幕 / 分镜 / 剧本)
      *
      * @return array
      */
     public static function emptyAnchor(): array
     {
         return [
-            'anime_id'   => 0,
-            'episode_id' => 0,
-            'act_id'     => 0,
-            'segment_id' => '',
-            'script_id'  => 0,
+            'anime_id'       => 0,
+            'episode_id'     => 0,
+            'episode_number' => 0,
+            'act_id'         => 0,
+            'segment_id'     => '',
+            'script_id'      => 0,
         ];
     }
 
@@ -126,11 +127,12 @@ class ScriptAnchor
                 $episodeId = self::episodeIdByNumber($animeId, $episodeNumber);
             }
 
-            // 4. 只有分集ID时补全动漫(用于取动漫绑定的剧本)
+            // 4. 只有分集ID时补全动漫与分集序号(用于取动漫绑定的剧本、回填积分明细分集)
             if ($episodeId > 0 && ($animeId <= 0 || $episodeNumber <= 0)) {
                 $episodeRow = self::fromEpisodeId($episodeId);
                 if ($episodeRow) {
                     $animeId = $animeId ?: (int)$episodeRow['anime_id'];
+                    $episodeNumber = $episodeNumber ?: (int)$episodeRow['episode_number'];
                 }
             }
 
@@ -138,11 +140,12 @@ class ScriptAnchor
             $animeScriptId = $animeId > 0 ? self::scriptIdByAnime($animeId) : 0;
             $scriptId      = $animeScriptId > 0 ? $animeScriptId : $scriptId;
 
-            $anchor['anime_id']   = $animeId;
-            $anchor['episode_id'] = $episodeId;
-            $anchor['act_id']     = $actId;
-            $anchor['segment_id'] = $segmentId;
-            $anchor['script_id']  = $scriptId;
+            $anchor['anime_id']       = $animeId;
+            $anchor['episode_id']     = $episodeId;
+            $anchor['episode_number'] = $episodeNumber;
+            $anchor['act_id']         = $actId;
+            $anchor['segment_id']     = $segmentId;
+            $anchor['script_id']      = $scriptId;
 
             return $anchor;
 
@@ -157,6 +160,7 @@ class ScriptAnchor
      * 将锚点合并进 charge_info
      *
      * - act_id / segment_id 统一按 alias_ 前缀记录:alias_act_id / alias_segment_id;
+     * - episode_number 与其它锚点一样写入 charge_info;
      * - charge_info 中已有的值优先(尤其任务参数已带的 alias_act_id / alias_segment_id),缺失的才补充;
      * - 未解析到的锚点不写入,避免冗余空值。
      *
@@ -189,7 +193,7 @@ class ScriptAnchor
     }
 
     /**
-     * 用已解析出的锚点补写 charge_info(已有值优先,仅补 anime_id / script_id)
+     * 用已解析出的锚点补写 charge_info(已有值优先,补 anime_id / episode_number / script_id)
      *
      * 用于积分明细落库时(PointsService):保证明细列与 charge_info 口径一致,
      * 已经写入过锚点的任务(视频/图片)不会重复写入。
@@ -197,13 +201,17 @@ class ScriptAnchor
      * @param array    $chargeInfo
      * @param int|null $animeId
      * @param int|null $scriptId
+     * @param int|null $episodeNumber 分集序号(与分集无关时传 0)
      * @return array
      */
-    public static function fillChargeInfoAnchors(array $chargeInfo, $animeId, $scriptId): array
+    public static function fillChargeInfoAnchors(array $chargeInfo, $animeId, $scriptId, $episodeNumber = 0): array
     {
         if ((int)$animeId > 0 && empty(getProp($chargeInfo, 'anime_id', ''))) {
             $chargeInfo['anime_id'] = (int)$animeId;
         }
+        if ((int)$episodeNumber > 0 && empty(getProp($chargeInfo, 'episode_number', ''))) {
+            $chargeInfo['episode_number'] = (int)$episodeNumber;
+        }
         if ((int)$scriptId > 0 && empty(getProp($chargeInfo, 'script_id', ''))) {
             $chargeInfo['script_id'] = (int)$scriptId;
         }

+ 2 - 0
app/Models/MpUserPointsDetail.php

@@ -27,6 +27,7 @@ class MpUserPointsDetail extends Model
         'cpid',
         'anime_id',
         'anime_name',
+        'episode_number',
         'script_id',
         'script_name',
         'task_id',
@@ -43,6 +44,7 @@ class MpUserPointsDetail extends Model
     protected $casts = [
         'charge_info' => 'array',
         'anime_id' => 'integer',
+        'episode_number' => 'integer',
         'script_id' => 'integer',
         'points_consumed' => 'float',
         'points_before' => 'float',

+ 88 - 1
app/Services/Anime/AnimeService.php

@@ -15,6 +15,7 @@ use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\DeepSeek\DeepSeekService;
 use App\Services\PointsService;
 use App\Services\TaskCenterService;
+
 use Dflydev\DotAccessData\Util;
 use GuzzleHttp\Client;
 use Illuminate\Support\Facades\DB;
@@ -286,7 +287,54 @@ class AnimeService
             $query->where('anime_name', 'like', "%$anime_name%");
         }
 
-        return $query->orderBy('id', 'desc')->paginate();
+        $result = $query->orderBy('id', 'desc')->paginate();
+
+        // 本剧积分消耗明细(points_consumed)
+        $this->attachPointsConsumed($result);
+
+        return $result;
+    }
+
+    /**
+     * 为动漫列表附加「本剧累计消耗积分」points_consumed(总额)
+     *
+     * 口径:只统计消耗(points_consumed > 0)的明细,与积分流水列表的消耗口径一致;
+     *      按 anime_id 一次性聚合,避免列表 N+1 查询。
+     *
+     * @param mixed $paginator 分页结果(LengthAwarePaginator)
+     * @return void
+     */
+    private function attachPointsConsumed($paginator): void
+    {
+        $items = $paginator->getCollection();
+
+        $animeIds = [];
+        foreach ($items as $item) {
+            $animeId = (int)getProp($item, 'id', 0);
+            if ($animeId > 0) {
+                $animeIds[$animeId] = true;
+            }
+        }
+        $animeIds = array_keys($animeIds);
+
+        // anime_id => 本剧累计消耗积分
+        $consumed = [];
+        if ($animeIds) {
+            $rows = DB::table('mp_user_points_details')
+                ->select('anime_id', DB::raw('SUM(points_consumed) as points_consumed'))
+                ->whereIn('anime_id', $animeIds)
+                ->where('points_consumed', '>', 0)
+                ->groupBy('anime_id')
+                ->get();
+
+            foreach ($rows as $row) {
+                $consumed[(int)$row->anime_id] = (float)$row->points_consumed;
+            }
+        }
+
+        foreach ($items as $item) {
+            $item->points_consumed = $consumed[(int)getProp($item, 'id', 0)] ?? 0.0;
+        }
     }
 
     public function chatHistory($data) {
@@ -579,6 +627,13 @@ class AnimeService
             return (array)$value;
         })->toArray();
 
+        // 各分集的积分消耗(episode_number => 累计消耗积分),写入对应分集
+        $pointsByEpisode = $this->pointsConsumedByEpisode($anime_id);
+        foreach ($episodes as &$episode) {
+            $episode['points_consumed'] = $pointsByEpisode[(int)getProp($episode, 'episode_number', 0)] ?? 0.0;
+        }
+        unset($episode);
+
         $anime['episodes'] = $episodes;
 
         // 获取当前仍在批量生成中的剧集任务(只展示 episodes 中尚未出现的剧集序号)
@@ -609,6 +664,38 @@ class AnimeService
     }
 
     /**
+     * 本剧各分集的积分消耗(episode_number => 累计消耗积分)
+     *
+     * 口径:只统计消耗(points_consumed > 0)的明细;未归属到分集(episode_number 为空)的明细不计入。
+     * 返回的数值会写入 animeDetail 的 episodes[].points_consumed。
+     *
+     * @param int $animeId
+     * @return array [episode_number => 累计消耗积分]
+     */
+    private function pointsConsumedByEpisode($animeId): array
+    {
+        $animeId = (int)$animeId;
+        if ($animeId <= 0) {
+            return [];
+        }
+
+        $rows = DB::table('mp_user_points_details')
+            ->select('episode_number', DB::raw('SUM(points_consumed) as points_consumed'))
+            ->where('anime_id', $animeId)
+            ->whereNotNull('episode_number')
+            ->where('points_consumed', '>', 0)
+            ->groupBy('episode_number')
+            ->get();
+
+        $result = [];
+        foreach ($rows as $row) {
+            $result[(int)$row->episode_number] = (float)$row->points_consumed;
+        }
+
+        return $result;
+    }
+
+    /**
      * 对外展示的动漫详情:animeDetail 的全部数据 + 前 N 集详情(格式同 episodeInfoForAce)
      * episodes 保持数组结构,包含前 N 集;N 由前端传入(episode_limit),默认 4
      */

+ 26 - 11
app/Services/PointsService.php

@@ -635,7 +635,7 @@ class PointsService
     /**
      * 导出积分明细 CSV(与列表筛选条件一致:uid/type/start_date/end_date)
      *
-     * 列:账号、类型、模型、分辨率、动漫/剧本、积分变动、变动前、变动后、Token、备注、创建时间
+     * 列:账号、类型、模型、分辨率、动漫/剧本、分集、积分变动、变动前、变动后、Token、备注、创建时间
      * 模型名做展示映射(隐藏百度/快快等供应商字样),分辨率按类型展示(视频 480p/超分720p、图片 宽*高)
      * 名称列取「动漫名优先,其次剧本名」
      * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据)
@@ -654,7 +654,7 @@ class PointsService
             ->orderBy('d.id', 'desc')
             ->get();
 
-        $headers = ['账号', '类型', '模型', '分辨率', '动漫/剧本', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
+        $headers = ['账号', '类型', '模型', '分辨率', '动漫/剧本', '分集', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
         $csvRows = [];
         $totalConsumed = 0;
         $totalRefunded = 0;
@@ -688,6 +688,7 @@ class PointsService
                 PointsDisplay::resolution($typeCode, $chargeInfo),
                 // 名称:动漫名优先,其次剧本名
                 (string)(getProp($row, 'anime_name', '') !== '' ? getProp($row, 'anime_name', '') : getProp($row, 'script_name', '')),
+                (string)(int)getProp($row, 'episode_number', 0),
                 $pointsChange,
                 (string)(float)getProp($row, 'points_before', 0),
                 (string)(float)getProp($row, 'points_after', 0),
@@ -699,7 +700,7 @@ class PointsService
 
         // 总计(最后一行单行展示,列数与表头对齐:备注列放汇总文案)
         $csvRows[] = [
-            '总计', '', '', '', '', '', '', '', '',
+            '总计', '', '', '', '', '', '', '', '', '',
             '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded,
             '',
         ];
@@ -1515,14 +1516,15 @@ class PointsService
             }
 
             $scriptContext = $this->resolveScriptContext($chargeInfo, null, $type);
-            // charge_info 与明细列口径一致:补写缺失的 anime_id / script_id(已有值保留)
-            $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id']);
+            // charge_info 与明细列口径一致:补写缺失的 anime_id / episode_number / script_id(已有值保留)
+            $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id'], $scriptContext['episode_number']);
 
             DB::table('mp_user_points_details')->insert([
                 'uid' => $uid,
                 'cpid' => $cpid,
                 'anime_id' => $scriptContext['anime_id'],
                 'anime_name' => $scriptContext['anime_name'],
+                'episode_number' => $scriptContext['episode_number'],
                 'script_id' => $scriptContext['script_id'],
                 'script_name' => $scriptContext['script_name'],
                 'task_id' => null,
@@ -1671,8 +1673,8 @@ class PointsService
 
             // 剧本维度(解析失败或与剧本无关时为空,不影响计费主流程)
             $scriptContext = $this->resolveScriptContext($chargeInfo, $taskId, $type);
-            // charge_info 与明细列口径一致:补写缺失的 anime_id / script_id(已有值保留)
-            $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id']);
+            // charge_info 与明细列口径一致:补写缺失的 anime_id / episode_number / script_id(已有值保留)
+            $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id'], $scriptContext['episode_number']);
 
             // 记录积分使用明细
             DB::table('mp_user_points_details')->insert([
@@ -1680,6 +1682,7 @@ class PointsService
                 'cpid' => (int)getProp($user, 'cpid', 0),
                 'anime_id' => $scriptContext['anime_id'],
                 'anime_name' => $scriptContext['anime_name'],
+                'episode_number' => $scriptContext['episode_number'],
                 'script_id' => $scriptContext['script_id'],
                 'script_name' => $scriptContext['script_name'],
                 'task_id' => $taskId,
@@ -1771,18 +1774,28 @@ class PointsService
      */
     public function resolveScriptContext(array $chargeInfo, ?int $taskId = null, string $type = ''): array
     {
-        $empty = ['anime_id' => null, 'anime_name' => null, 'script_id' => null, 'script_name' => null];
+        $empty = ['anime_id' => null, 'anime_name' => null, 'episode_id' => null, 'episode_number' => null, 'script_id' => null, 'script_name' => null];
 
         try {
             // 锚点解析:动漫/剧集/剧幕/分镜 + 剧本(动漫绑定剧本优先,其次上下文剧本ID)
             $anchor = ScriptAnchor::resolve($chargeInfo);
             $animeId = (int)$anchor['anime_id'];
+            $episodeId = (int)$anchor['episode_id'];
+            $episodeNumber = (int)$anchor['episode_number'];
             $scriptId = (int)$anchor['script_id'];
 
-            // 动漫/剧集上下文缺失时,回查任务表保存的关联片段(历史数据兼容)
-            if ($animeId <= 0 && $taskId) {
+            // 动漫/分集上下文缺失时,回查任务表保存的关联片段(历史数据兼容)
+            if (($animeId <= 0 || $episodeNumber <= 0) && $taskId) {
                 $taskAnchor = ScriptAnchor::anchorByTask($type, (int)$taskId);
-                $animeId = (int)$taskAnchor['anime_id'];
+                if ($animeId <= 0) {
+                    $animeId = (int)$taskAnchor['anime_id'];
+                }
+                if ($episodeId <= 0) {
+                    $episodeId = (int)$taskAnchor['episode_id'];
+                }
+                if ($episodeNumber <= 0) {
+                    $episodeNumber = (int)$taskAnchor['episode_number'];
+                }
                 if ($scriptId <= 0) {
                     $scriptId = (int)$taskAnchor['script_id'];
                 }
@@ -1802,6 +1815,8 @@ class PointsService
             return [
                 'anime_id' => $animeId > 0 ? $animeId : null,
                 'anime_name' => $animeName !== '' ? $animeName : null,
+                'episode_id' => $episodeId > 0 ? $episodeId : null,
+                'episode_number' => $episodeNumber > 0 ? $episodeNumber : null,
                 // 剧本未知或已不存在时只保留动漫维度
                 'script_id' => $scriptName !== '' ? $scriptId : null,
                 'script_name' => $scriptName !== '' ? $scriptName : null,

+ 2 - 0
app/Transformer/Anime/AnimeTransformer.php

@@ -26,6 +26,8 @@ class AnimeTransformer
                 'first_frame_url'   => getProp($item, 'first_frame_url'),
                 'is_multi'          => getProp($item, 'is_multi'),
                 'ace_mode'          => getProp($item, 'ace_mode'),
+                // 本剧累计消耗积分(总额)
+                'points_consumed'   => (float)getProp($item, 'points_consumed', 0),
                 'created_at'        => transDate(getProp($item, 'created_at')),
             ];
         }

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

@@ -37,6 +37,7 @@ class PointsTransformer
      * 模型名统一做展示映射(隐藏百度/快快等供应商字样);
      * 分辨率按类型展示:视频 480p/720p/1080p/4k 与 超分720p/超分1080p,图片 实际宽*高,其余为空。
      * 名称字段只返回一个 anime_name:动漫名优先,其次剧本名(与动漫/剧本无关时为空)。
+     * 分集序号 episode_number:与分集无关的记录返回 0。
      * 模型名保持原键名 charge_info.model 不变,值改为展示名(隐藏供应商字样),前端无需调整取数位置。
      *
      * @param $list
@@ -89,6 +90,7 @@ class PointsTransformer
                 'task_id' => (int)getProp($item, 'task_id'),
                 'resolution' => $resolution,
                 'anime_id' => (int)getProp($item, 'anime_id', 0),
+                'episode_number' => (int)getProp($item, 'episode_number', 0),
                 'script_id' => (int)getProp($item, 'script_id', 0),
                 'anime_name' => $displayName,
                 'charge_info' => $chargeInfo,