Ver código fonte

1.计费信息json新增多个来源ID记录,包括剧本和动漫ID、分集ID等
2.积分明细增加动漫剧本名筛选及分辨率显示
3.积分明细模型去掉百度、快快等供应商字样

lh 2 dias atrás
pai
commit
a31f98f279

+ 6 - 2
app/Console/Anime/AnimeCheckImgUrlCommand.php

@@ -285,7 +285,9 @@ class AnimeCheckImgUrlCommand extends Command
 
                         $params = [
                             'prompt' => $description,
-                            'ref_img_urls' => []
+                            'ref_img_urls' => [],
+                            // 计费锚点(动漫/剧本),用于积分明细归属统计
+                            'anime_id' => $id,
                         ];
                         
                         $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -361,7 +363,9 @@ class AnimeCheckImgUrlCommand extends Command
 
                         $params = [
                             'prompt' => $description,
-                            'ref_img_urls' => []
+                            'ref_img_urls' => [],
+                            // 计费锚点(动漫/剧本),用于积分明细归属统计
+                            'anime_id' => $id,
                         ];
                         
                         $task = $this->aiImageGenerationService->createImageGenerationTask($params);

+ 141 - 0
app/Console/Commands/BackfillPointsScriptCommand.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\PointsService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class BackfillPointsScriptCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'points:backfill-script
+                            {--type= : 只回填指定类型(video/image/chat),默认三种类型全部回填}
+                            {--dry-run : 只统计将回填的记录数,不实际更新}
+                            {--force : 重算已有锚点的记录(默认只处理缺失动漫/剧本的记录)}
+                            {--limit= : 最多处理记录数(默认全部)}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '回填积分明细的动漫ID/动漫名与剧本ID/剧本名(历史数据无动漫/剧本维度时为空)';
+
+    /**
+     * 每批处理条数
+     */
+    const CHUNK_SIZE = 500;
+
+    /**
+     * Execute the console command.
+     *
+     * @param PointsService $pointsService
+     * @return int
+     */
+    public function handle(PointsService $pointsService)
+    {
+        if (!Schema::hasColumn('mp_user_points_details', 'script_id')) {
+            $this->error('mp_user_points_details 缺少 script_id/script_name 字段,请先执行 php artisan migrate');
+
+            return 1;
+        }
+
+        $dryRun = (bool)$this->option('dry-run');
+        $force = (bool)$this->option('force');
+        $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'];
+
+        $query = DB::table('mp_user_points_details')
+            ->select('id', 'type', 'task_id', 'charge_info')
+            ->whereIn('type', $types)
+            ->orderBy('id');
+
+        if (!$force) {
+            $query->where(function ($q) {
+                $q->whereNull('anime_id')
+                    ->orWhereNull('anime_name')
+                    ->orWhereNull('script_id')
+                    ->orWhere('script_id', 0);
+            });
+        }
+
+        $total = (clone $query)->count();
+        $this->info('待回填明细总数: ' . $total . ($dryRun ? '(dry-run 模式,不更新)' : ''));
+        if ($total <= 0) {
+            return 0;
+        }
+        if ($limit > 0) {
+            $this->info('本次最多处理: ' . $limit . ' 条');
+        }
+
+        $scanned = 0;
+        $filled = 0;
+        $skipped = 0;
+
+        $query->chunkById(self::CHUNK_SIZE, function ($rows) use ($pointsService, $dryRun, $limit, &$scanned, &$filled, &$skipped) {
+            /** @var array 剧本 => 待更新明细ID */
+            $pending = [];
+
+            foreach ($rows as $row) {
+                if ($limit > 0 && $scanned >= $limit) {
+                    return false;
+                }
+                $scanned++;
+
+                $chargeInfo = $row->charge_info;
+                if (is_string($chargeInfo)) {
+                    $chargeInfo = json_decode($chargeInfo, true);
+                }
+                $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
+
+                $context = $pointsService->resolveScriptContext(
+                    $chargeInfo,
+                    $row->task_id === null ? null : (int)$row->task_id,
+                    (string)$row->type
+                );
+
+                if (empty($context['anime_id']) && empty($context['script_id'])) {
+                    $skipped++;
+                    continue;
+                }
+
+                $key = (int)$context['anime_id'] . '|' . (string)$context['anime_name'] . '|' . (int)$context['script_id'] . '|' . (string)$context['script_name'];
+                $pending[$key][] = (int)$row->id;
+            }
+
+            foreach ($pending as $key => $ids) {
+                list($animeId, $animeName, $scriptId, $scriptName) = explode('|', $key, 4);
+                $filled += count($ids);
+
+                if ($dryRun) {
+                    continue;
+                }
+
+                DB::table('mp_user_points_details')->whereIn('id', $ids)->update([
+                    'anime_id' => (int)$animeId ?: null,
+                    'anime_name' => $animeName !== '' ? $animeName : null,
+                    'script_id' => (int)$scriptId ?: null,
+                    'script_name' => $scriptName !== '' ? $scriptName : null,
+                ]);
+            }
+
+            return true;
+        });
+
+        $this->info('扫描明细: ' . $scanned . ' 条,可回填: ' . $filled . ' 条,与剧本无关: ' . $skipped . ' 条');
+        $this->info($dryRun ? 'dry-run 结束,未更新任何数据' : '回填完成');
+
+        return 0;
+    }
+}

+ 218 - 0
app/Libs/PointsDisplay.php

@@ -0,0 +1,218 @@
+<?php
+
+namespace App\Libs;
+
+use App\Models\MpUserPointsDetail;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 积分明细展示辅助类
+ *
+ * 统一定义积分流水(列表/导出)对外展示的模型名与分辨率:
+ * - 模型名:隐藏供应商字样(百度/快快AI/智帧/字节跳动等),如百度Seedance2.0 → Seedance2.0
+ *   模型展示名优先取配置表(mp_video_models / mp_image_models / mp_text_models)的 name,
+ *   配置缺失时回退内置映射,仍未命中则仅剥离供应商前缀原样返回。
+ * - 分辨率:视频显示 480p/720p/1080p/4k 与 超分720p/超分1080p;图片显示 实际宽*高(如 1600*2848);
+ *   其他类型(AI对话/积分发放等)返回空串。
+ */
+class PointsDisplay
+{
+    /**
+     * 需要从展示名中隐藏的供应商字样(仅处理名称开头的前缀)
+     */
+    const VENDOR_WORDS = [
+        '百度',
+        '快快AI',
+        '快快',
+        '智帧平台',
+        '智帧',
+        '字节跳动',
+    ];
+
+    /**
+     * 英文供应商前缀(模型ID开头)
+     */
+    const VENDOR_PREFIXES = [
+        'baidu-',
+        'kuaikuai-',
+    ];
+
+    /**
+     * 配置表缺失时的内置兜底映射(模型ID => 展示名)
+     */
+    const MODEL_NAME_FALLBACK = [
+        // 视频(Seedance 系列)
+        'doubao-seedance-1-5-pro-251215'       => 'Seedance1.5pro',
+        'doubao-seedance-1-0-pro-250528'       => 'Seedance1.0pro',
+        'doubao-seedance-1-0-pro-fast-251015'  => 'Seedance1.0profast',
+        'doubao-seedance-2-0-260128'           => 'Seedance2.0',
+        'doubao-seedance-2-0-fast-260128'      => 'Seedance2.0fast',
+        'doubao-seedance-2.0'                  => 'Seedance2.0',
+        'baidu-doubao-seedance-2-0-260128'     => 'Seedance2.0',
+        'baidu-doubao-seedance-2-0-fast-260128' => 'Seedance2.0fast',
+        'baidu-doubao-seedance-2-0-mini-260615' => 'Seedance2.0mini',
+        'baidu-doubao-seedance-2-5-260628'     => 'Seedance2.5',
+        'baidu-dreamina-seedance-2-0-260128'      => 'Seedance2.0(海外)',
+        'baidu-dreamina-seedance-2-0-fast-260128' => 'Seedance2.0fast(海外)',
+        'baidu-dreamina-seedance-2-0-mini-260615' => 'Seedance2.0mini(海外)',
+        'baidu-dreamina-seedance-2-5-260628'      => 'Seedance2.5(海外)',
+        'zhizhen-20'                           => 'Seedance2.0',
+        'zhizhen-20-fast'                      => 'Seedance2.0fast',
+        'zhizhen-20-mini'                      => 'Seedance2.0mini',
+        'seed-2'                               => 'Seedance2.0',
+        'seed-2-fast'                          => 'Seedance2.0fast',
+        'seed-2-mini'                          => 'Seedance2.0mini',
+        'seed-2.5'                             => 'Seedance2.5',
+        // 文本
+        'baidu-glm-5.2'                        => 'GLM-5.2',
+        'baidu-glm-5.3'                        => 'GLM-5.3',
+    ];
+
+    /**
+     * 视频分辨率取值 => 展示文案
+     */
+    const VIDEO_RESOLUTION_LABELS = [
+        '480p'       => '480p',
+        '720p'       => '720p',
+        '1080p'      => '1080p',
+        '4k'         => '4k',
+        '2160p'      => '4k',
+        '4096x2160'  => '4k',
+        'sr_720p'    => '超分720p',
+        'sr720p'     => '超分720p',
+        '超分720p'    => '超分720p',
+        'sr_1080p'   => '超分1080p',
+        'sr1080p'    => '超分1080p',
+        '超分1080p'   => '超分1080p',
+    ];
+
+    /**
+     * 配置表读取的模型展示名缓存(单次请求内只查一次)
+     *
+     * @var array|null
+     */
+    private static $modelNameMap = null;
+
+    /**
+     * 获取模型对外展示名(隐藏百度/快快等供应商字样)
+     *
+     * @param string $model 模型ID(charge_info.model)
+     * @return string
+     */
+    public static function modelName(string $model = ''): string
+    {
+        $model = trim($model);
+        if ($model === '' || $model === 'unknown') {
+            return '';
+        }
+
+        $map = self::modelNameMap();
+        $name = $map[$model] ?? self::MODEL_NAME_FALLBACK[$model] ?? '';
+        if ($name === '') {
+            $name = $model;
+        }
+
+        return self::stripVendor($name);
+    }
+
+    /**
+     * 获取明细的分辨率展示文案
+     *
+     * - 视频:480p/720p/1080p/4k、超分720p、超分1080p(来自 charge_info.video_resolution)
+     * - 图片:实际宽*高,如 1600*2848(来自 charge_info.width / height)
+     * - 其他类型或无数据:空串
+     *
+     * @param string $type       明细类型(video/image/chat/system/company)
+     * @param array  $chargeInfo 计费信息
+     * @return string
+     */
+    public static function resolution(string $type, array $chargeInfo = []): string
+    {
+        if ($type === MpUserPointsDetail::TYPE_VIDEO) {
+            $resolution = strtolower(trim((string)getProp($chargeInfo, 'video_resolution', '')));
+            if ($resolution === '') {
+                return '';
+            }
+
+            return self::VIDEO_RESOLUTION_LABELS[$resolution] ?? $resolution;
+        }
+
+        if ($type === MpUserPointsDetail::TYPE_IMAGE) {
+            $width = (int)getProp($chargeInfo, 'width', 0);
+            $height = (int)getProp($chargeInfo, 'height', 0);
+            if ($width <= 0 || $height <= 0) {
+                return '';
+            }
+
+            return $width . '*' . $height;
+        }
+
+        return '';
+    }
+
+    /**
+     * 仅保留名称主体:剥离供应商字样前缀(百度Seedance2.0 → Seedance2.0,baidu-xxx → xxx)
+     *
+     * @param string $name
+     * @return string
+     */
+    private static function stripVendor(string $name): string
+    {
+        $name = trim($name);
+
+        // 英文前缀(模型ID形态)
+        foreach (self::VENDOR_PREFIXES as $prefix) {
+            if (stripos($name, $prefix) === 0) {
+                $name = trim(substr($name, strlen($prefix)));
+            }
+        }
+
+        // 中文前缀(配置表 name 形态),可能连续出现(如“百度智帧xxx”)
+        $changed = true;
+        while ($changed) {
+            $changed = false;
+            foreach (self::VENDOR_WORDS as $word) {
+                if (mb_strpos($name, $word) === 0) {
+                    $name = trim(mb_substr($name, mb_strlen($word)));
+                    $changed = true;
+                    break;
+                }
+            }
+        }
+
+        return $name;
+    }
+
+    /**
+     * 模型ID => 配置表展示名(读取失败或未命中时返回空数组,由调用方走兜底映射)
+     *
+     * @return array
+     */
+    private static function modelNameMap(): array
+    {
+        if (self::$modelNameMap !== null) {
+            return self::$modelNameMap;
+        }
+
+        $map = [];
+        foreach (['mp_video_models', 'mp_image_models', 'mp_text_models'] as $table) {
+            try {
+                $rows = DB::table($table)->select('model', 'name')->get();
+            } catch (\Throwable $e) {
+                continue;
+            }
+
+            foreach ($rows as $row) {
+                $model = trim((string)getProp($row, 'model', ''));
+                $name = trim((string)getProp($row, 'name', ''));
+                if ($model !== '' && $name !== '') {
+                    $map[$model] = $name;
+                }
+            }
+        }
+
+        self::$modelNameMap = $map;
+
+        return self::$modelNameMap;
+    }
+}

+ 408 - 0
app/Libs/ScriptAnchor.php

@@ -0,0 +1,408 @@
+<?php
+
+namespace App\Libs;
+
+use App\Models\MpUserPointsDetail;
+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 / act_id / segment_id / script_id / group_id
+ * - 历史参数:alias_act_id(mp_episode_segments.id)、alias_segment_id(mp_episode_segments.segment_id / 分镜号)
+ *
+ * 规则:
+ * - 有分镜行ID或分镜号时,回查 mp_episode_segments 补全动漫、剧集、剧幕/分镜锚点;
+ * - 剧本优先取动漫绑定的剧本(mp_animes.script_id),其次取上下文显式传入的 script_id;
+ * - 推导不到的一律返回 0 / 空串,异常只记日志,绝不影响任务创建与计费主流程。
+ */
+class ScriptAnchor
+{
+    /**
+     * 分镜表读取字段(只取锚点链路需要的列)
+     */
+    const SEGMENT_COLUMNS = [
+        'id',
+        'anime_id',
+        'episode_id',
+        'episode_number',
+        'segment_id',
+    ];
+
+    /** @var array 分镜行ID => 行数据 */
+    private static $segmentRowCache = [];
+
+    /** @var array 分镜号 => 行数据 */
+    private static $segmentNoCache = [];
+
+    /** @var array 动漫ID => 动漫行数据(anime_name、script_id) */
+    private static $animeRowCache = [];
+
+    /** @var array 剧本分集分组ID => 剧本ID */
+    private static $groupScriptCache = [];
+
+    /** @var array 剧本ID => 剧本名 */
+    private static $scriptNameCache = [];
+
+    /** @var array 动漫ID-集数 => 分集ID */
+    private static $episodeIdCache = [];
+
+    /** @var array 分集ID => 分集行数据 */
+    private static $episodeRowCache = [];
+
+    /** @var array 任务表锚点缓存 */
+    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,
+        ];
+    }
+
+    /**
+     * 由上下文(任务创建参数或 charge_info)解析完整锚点
+     *
+     * @param array $context
+     * @return array 见 emptyAnchor(),缺失项为 0 / 空串
+     */
+    public static function resolve(array $context): array
+    {
+        $anchor = self::emptyAnchor();
+
+        try {
+            // 1. 上下文显式传入的锚点
+            $animeId       = (int)getProp($context, 'anime_id', 0);
+            $episodeId     = (int)getProp($context, 'episode_id', 0);
+            $episodeNumber = (int)getProp($context, 'episode_number', 0);
+            $scriptId      = (int)getProp($context, 'script_id', 0);
+
+            $actId = (int)getProp($context, 'act_id', 0);
+            if ($actId <= 0) {
+                $actId = (int)getProp($context, 'alias_act_id', 0);
+            }
+
+            $segmentId = self::normalizeSegmentNo(getProp($context, 'segment_id', ''));
+            if ($segmentId === '') {
+                $segmentId = self::normalizeSegmentNo(getProp($context, 'alias_segment_id', ''));
+            }
+
+            // 2. 由分镜行/分镜号补全动漫、剧集、剧幕/分镜
+            $segmentRow = [];
+            if ($actId > 0) {
+                $segmentRow = self::fromSegmentRowId($actId);
+            } elseif ($segmentId !== '') {
+                $segmentRow = self::fromSegmentNo($segmentId);
+            }
+
+            if (!empty($segmentRow)) {
+                $animeId       = $animeId ?: (int)$segmentRow['anime_id'];
+                $episodeId     = $episodeId ?: (int)$segmentRow['episode_id'];
+                $episodeNumber = $episodeNumber ?: (int)$segmentRow['episode_number'];
+                $actId         = (int)$segmentRow['id'];
+                if ($segmentId === '') {
+                    $segmentId = (string)$segmentRow['segment_id'];
+                }
+            }
+
+            // 3. 只有动漫 + 集数时补全分集ID
+            if ($episodeId <= 0 && $animeId > 0 && $episodeNumber > 0) {
+                $episodeId = self::episodeIdByNumber($animeId, $episodeNumber);
+            }
+
+            // 4. 只有分集ID时补全动漫(用于取动漫绑定的剧本)
+            if ($episodeId > 0 && ($animeId <= 0 || $episodeNumber <= 0)) {
+                $episodeRow = self::fromEpisodeId($episodeId);
+                if ($episodeRow) {
+                    $animeId = $animeId ?: (int)$episodeRow['anime_id'];
+                }
+            }
+
+            // 5. 剧本:动漫绑定优先,其次上下文脚本ID
+            $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;
+
+            return $anchor;
+
+        } catch (\Throwable $e) {
+            dLog('points')->warning('锚点解析失败(不影响主流程): ' . $e->getMessage());
+
+            return $anchor;
+        }
+    }
+
+    /**
+     * 将锚点合并进 charge_info
+     *
+     * - act_id / segment_id 统一按 alias_ 前缀记录:alias_act_id / alias_segment_id;
+     * - charge_info 中已有的值优先(尤其任务参数已带的 alias_act_id / alias_segment_id),缺失的才补充;
+     * - 未解析到的锚点不写入,避免冗余空值。
+     *
+     * @param array $chargeInfo 已构建好的计费信息
+     * @param array $context    任务创建参数 / 计费上下文
+     * @return array 合并后的计费信息
+     */
+    public static function mergeIntoChargeInfo(array $chargeInfo, array $context): array
+    {
+        $anchor = self::resolve($context);
+
+        // act_id / segment_id 统一转换成 alias_ 前缀
+        $anchor['alias_act_id'] = $anchor['act_id'];
+        $anchor['alias_segment_id'] = $anchor['segment_id'];
+        unset($anchor['act_id'], $anchor['segment_id']);
+
+        foreach ($anchor as $key => $value) {
+            if ($value === 0 || $value === '' || $value === null) {
+                continue;
+            }
+            // 已有值优先(alias_ 开头的历史字段保留原值)
+            if (!empty(getProp($chargeInfo, $key, ''))) {
+                continue;
+            }
+
+            $chargeInfo[$key] = $value;
+        }
+
+        return $chargeInfo;
+    }
+
+    /**
+     * 分镜行ID(mp_episode_segments.id)→ 分镜行数据
+     *
+     * @param int $rowId
+     * @return array
+     */
+    public static function fromSegmentRowId(int $rowId): array
+    {
+        if ($rowId <= 0) {
+            return [];
+        }
+        if (!array_key_exists($rowId, self::$segmentRowCache)) {
+            $row = DB::table('mp_episode_segments')->where('id', $rowId)->first(self::SEGMENT_COLUMNS);
+            self::$segmentRowCache[$rowId] = $row ? (array)$row : [];
+        }
+
+        return self::$segmentRowCache[$rowId];
+    }
+
+    /**
+     * 分镜号(mp_episode_segments.segment_id)→ 分镜行数据
+     *
+     * @param string $segmentNo
+     * @return array
+     */
+    public static function fromSegmentNo(string $segmentNo): array
+    {
+        $segmentNo = self::normalizeSegmentNo($segmentNo);
+        if ($segmentNo === '') {
+            return [];
+        }
+        if (!array_key_exists($segmentNo, self::$segmentNoCache)) {
+            $row = DB::table('mp_episode_segments')->where('segment_id', $segmentNo)->first(self::SEGMENT_COLUMNS);
+            self::$segmentNoCache[$segmentNo] = $row ? (array)$row : [];
+        }
+
+        return self::$segmentNoCache[$segmentNo];
+    }
+
+    /**
+     * 视频/图片任务ID → 锚点(回查任务表保存的关联片段,兼容未写锚点的历史任务)
+     *
+     * @param string $type   明细类型(video/image)
+     * @param int    $taskId 任务表主键
+     * @return array
+     */
+    public static function anchorByTask(string $type, int $taskId): array
+    {
+        if ($taskId <= 0 || !in_array($type, [MpUserPointsDetail::TYPE_VIDEO, MpUserPointsDetail::TYPE_IMAGE], true)) {
+            return self::emptyAnchor();
+        }
+
+        $cacheKey = $type . ':' . $taskId;
+        if (!array_key_exists($cacheKey, self::$taskAnchorCache)) {
+            $table = $type === MpUserPointsDetail::TYPE_VIDEO ? 'mp_generate_video_tasks' : 'mp_generate_pic_tasks';
+            $task = DB::table($table)->where('id', $taskId)->first(['alias_act_id', 'alias_segment_id']);
+
+            $anchor = self::emptyAnchor();
+            if ($task) {
+                $context = [];
+                if ((int)getProp($task, 'alias_act_id', 0) > 0) {
+                    $context['alias_act_id'] = (int)getProp($task, 'alias_act_id', 0);
+                }
+                $segmentNo = self::normalizeSegmentNo(getProp($task, 'alias_segment_id', ''));
+                if ($segmentNo !== '') {
+                    $context['alias_segment_id'] = $segmentNo;
+                }
+                if ($context) {
+                    $anchor = self::resolve($context);
+                }
+            }
+
+            self::$taskAnchorCache[$cacheKey] = $anchor;
+        }
+
+        return self::$taskAnchorCache[$cacheKey];
+    }
+
+    /**
+     * 动漫ID → 绑定剧本ID(未绑定返回 0)
+     *
+     * @param int $animeId
+     * @return int
+     */
+    public static function scriptIdByAnime(int $animeId): int
+    {
+        $animeRow = self::animeRowById($animeId);
+
+        return (int)($animeRow['script_id'] ?? 0);
+    }
+
+    /**
+     * 动漫ID → 动漫名(动漫不存在返回空串)
+     *
+     * @param int $animeId
+     * @return string
+     */
+    public static function animeNameById(int $animeId): string
+    {
+        $animeRow = self::animeRowById($animeId);
+
+        return trim((string)($animeRow['anime_name'] ?? ''));
+    }
+
+    /**
+     * 动漫ID → 动漫行数据
+     *
+     * @param int $animeId
+     * @return array
+     */
+    private static function animeRowById(int $animeId): array
+    {
+        if ($animeId <= 0) {
+            return [];
+        }
+        if (!array_key_exists($animeId, self::$animeRowCache)) {
+            $row = DB::table('mp_animes')->where('id', $animeId)->first(['id', 'anime_name', 'script_id']);
+            self::$animeRowCache[$animeId] = $row ? (array)$row : [];
+        }
+
+        return self::$animeRowCache[$animeId];
+    }
+
+    /**
+     * 剧本分集分组ID(mp_script_episode_group.id)→ 剧本ID
+     *
+     * @param int $groupId
+     * @return int
+     */
+    public static function scriptIdByGroup(int $groupId): int
+    {
+        if ($groupId <= 0) {
+            return 0;
+        }
+        if (!array_key_exists($groupId, self::$groupScriptCache)) {
+            self::$groupScriptCache[$groupId] = (int)DB::table('mp_script_episode_group')->where('id', $groupId)->value('script_id');
+        }
+
+        return (int)self::$groupScriptCache[$groupId];
+    }
+
+    /**
+     * 剧本ID → 剧本名(剧本不存在返回空串)
+     *
+     * @param int $scriptId
+     * @return string
+     */
+    public static function scriptNameById(int $scriptId): string
+    {
+        if ($scriptId <= 0) {
+            return '';
+        }
+        if (!array_key_exists($scriptId, self::$scriptNameCache)) {
+            self::$scriptNameCache[$scriptId] = trim((string)DB::table('mp_scripts')->where('id', $scriptId)->value('script_name'));
+        }
+
+        return (string)self::$scriptNameCache[$scriptId];
+    }
+
+    /**
+     * 动漫ID + 集数 → 分集ID
+     *
+     * @param int $animeId
+     * @param int $episodeNumber
+     * @return int
+     */
+    public static function episodeIdByNumber(int $animeId, int $episodeNumber): int
+    {
+        if ($animeId <= 0 || $episodeNumber <= 0) {
+            return 0;
+        }
+
+        $cacheKey = $animeId . '-' . $episodeNumber;
+        if (!array_key_exists($cacheKey, self::$episodeIdCache)) {
+            self::$episodeIdCache[$cacheKey] = (int)DB::table('mp_anime_episodes')
+                ->where('anime_id', $animeId)
+                ->where('episode_number', $episodeNumber)
+                ->value('id');
+        }
+
+        return (int)self::$episodeIdCache[$cacheKey];
+    }
+
+    /**
+     * 分集ID → 分集行数据(mp_anime_episodes)
+     *
+     * @param int $episodeId
+     * @return array
+     */
+    public static function fromEpisodeId(int $episodeId): array
+    {
+        if ($episodeId <= 0) {
+            return [];
+        }
+        if (!array_key_exists($episodeId, self::$episodeRowCache)) {
+            $row = DB::table('mp_anime_episodes')->where('id', $episodeId)->first(['id', 'anime_id', 'episode_number']);
+            self::$episodeRowCache[$episodeId] = $row ? (array)$row : [];
+        }
+
+        return self::$episodeRowCache[$episodeId];
+    }
+
+    /**
+     * 归一化分镜号:空、0、字符串 "0" 均视为未传
+     *
+     * @param mixed $segmentNo
+     * @return string
+     */
+    private static function normalizeSegmentNo($segmentNo): string
+    {
+        $segmentNo = trim((string)$segmentNo);
+        if ($segmentNo === '' || $segmentNo === '0') {
+            return '';
+        }
+
+        return $segmentNo;
+    }
+}

+ 6 - 0
app/Models/MpUserPointsDetail.php

@@ -25,6 +25,10 @@ class MpUserPointsDetail extends Model
     protected $fillable = [
         'uid',
         'cpid',
+        'anime_id',
+        'anime_name',
+        'script_id',
+        'script_name',
         'task_id',
         'type',
         'api_type',
@@ -38,6 +42,8 @@ class MpUserPointsDetail extends Model
 
     protected $casts = [
         'charge_info' => 'array',
+        'anime_id' => 'integer',
+        'script_id' => 'integer',
         'points_consumed' => 'float',
         'points_before' => 'float',
         'points_after' => 'float',

+ 5 - 1
app/Services/AIGeneration/AIImageGenerationService.php

@@ -4,6 +4,7 @@ namespace App\Services\AIGeneration;
 
 use App\Facade\Site;
 use App\Consts\BaseConst;
+use App\Libs\ScriptAnchor;
 use App\Models\MpGeneratePicTask;
 use App\Models\MpAsset;
 use App\Services\PointsService;
@@ -71,7 +72,7 @@ class AIImageGenerationService
         $width = (int)getProp($params, 'width', 1600);
         $height = (int)getProp($params, 'height', 2848);
 
-        return [
+        $chargeInfo = [
             'user_id' => $uid,
             'model' => $model,
             'width' => $width,
@@ -82,6 +83,9 @@ class AIImageGenerationService
             'alias_segment_id' => $params['alias_segment_id'] ?? '',
             'created_at' => date('Y-m-d H:i:s'),
         ];
+
+        // 锚点数据(anime_id / episode_id / script_id,以及 alias_act_id / alias_segment_id),上下文没有的不记录
+        return ScriptAnchor::mergeIntoChargeInfo($chargeInfo, $params);
     }
 
     /**

+ 5 - 1
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -3,6 +3,7 @@
 namespace App\Services\AIGeneration;
 
 use App\Facade\Site;
+use App\Libs\ScriptAnchor;
 use App\Libs\Utils;
 use App\Models\MpAsset;
 use App\Models\MpGenerateVideoTask;
@@ -69,7 +70,7 @@ class AIVideoGenerationService
         // 带参考视频时按 video_to_video 定价(无该定价时积分引擎自动回退 video_generation)
         $hasReferenceVideo = !empty($params['reference_video_assets']) || !empty($params['reference_videos']);
 
-        return [
+        $chargeInfo = [
             'user_id' => $uid,
             'model' => $model,
             'video_resolution' => strtolower($params['video_resolution'] ?? '720P'),
@@ -83,6 +84,9 @@ class AIVideoGenerationService
             'alias_act_id' => $params['alias_act_id'] ?? '',
             'created_at' => date('Y-m-d H:i:s'),
         ];
+
+        // 锚点数据(anime_id / episode_id / script_id,以及 alias_act_id / alias_segment_id),上下文没有的不记录
+        return ScriptAnchor::mergeIntoChargeInfo($chargeInfo, $params);
     }
 
     /**

+ 35 - 8
app/Services/Anime/AnimeService.php

@@ -341,7 +341,11 @@ class AnimeService
             try {
                 $params = [
                     'prompt' => $description,
-                    'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : []
+                    'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : [],
+                    // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
+                    'episode_id' => $episode_id,
+                    'episode_number' => getProp($episode, 'episode_number'),
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -410,7 +414,11 @@ class AnimeService
             try {
                 $params = [
                     'prompt' => $description,
-                    'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : []
+                    'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : [],
+                    // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
+                    'episode_id' => $episode_id,
+                    'episode_number' => getProp($episode, 'episode_number'),
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -6282,7 +6290,10 @@ class AnimeService
                 'ref_img_urls' => '',
                 'width' => $width,
                 'height' => $height,
-                'image_num' => $imageNum
+                'image_num' => $imageNum,
+                // 计费锚点(动漫/剧集/剧本),上下文没有时不记录
+                'anime_id' => getProp($data, 'anime_id'),
+                'episode_id' => $episode_id,
             ];
             if ($ref_img_urls) $params['ref_img_urls'] = is_array($ref_img_urls) ? $ref_img_urls : [$ref_img_urls];
             
@@ -6524,7 +6535,11 @@ class AnimeService
                     
                     $params = [
                         'prompt' => $description,
-                        'ref_img_urls' => []
+                        'ref_img_urls' => [],
+                        // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                        'anime_id' => $anime_id,
+                        'episode_id' => $episode_id,
+                        'episode_number' => getProp($episode, 'episode_number'),
                     ];
                     
                     $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -6598,7 +6613,11 @@ class AnimeService
                     
                     $params = [
                         'prompt' => $description,
-                        'ref_img_urls' => []
+                        'ref_img_urls' => [],
+                        // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                        'anime_id' => $anime_id,
+                        'episode_id' => $episode_id,
+                        'episode_number' => getProp($episode, 'episode_number'),
                     ];
                     
                     $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -9572,6 +9591,8 @@ class AnimeService
                             'ref_img_urls' => [],
                             'width'        => $width,
                             'height'       => $height,
+                            // 计费锚点(剧本),用于积分明细归属统计
+                            'script_id'    => $script_id,
                         ]);
 
                         $task_id = $task->id;
@@ -9793,7 +9814,9 @@ class AnimeService
                                 'model' => $model,
                                 'ref_img_urls' => [],
                                 'width' => $width,
-                                'height' => $height
+                                'height' => $height,
+                                // 计费锚点(剧本),用于积分明细归属统计
+                                'script_id' => $script_id,
                             ];
                             
                             $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -10127,7 +10150,9 @@ class AnimeService
                             'model' => $model,
                             'ref_img_urls' => [],
                             'width' => $width,
-                            'height' => $height
+                            'height' => $height,
+                            // 计费锚点(剧本),用于积分明细归属统计
+                            'script_id' => $script_id,
                         ];
                         
                         $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -10434,7 +10459,9 @@ class AnimeService
                     'model' => $model,
                     'ref_img_urls' => [],
                     'width' => $width,
-                    'height' => $height
+                    'height' => $height,
+                    // 计费锚点(剧本),用于积分明细归属统计
+                    'script_id' => $script_id,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);

+ 18 - 5
app/Services/DeepSeek/DeepSeekService.php

@@ -15362,7 +15362,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 $params = [
                     'prompt' => $description,
                     // 'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : []
-                    'ref_img_urls' => []
+                    'ref_img_urls' => [],
+                    // 计费锚点(动漫/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -15422,7 +15424,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 $params = [
                     'prompt' => $description,
                     // 'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : [],
-                    'ref_img_urls' => []
+                    'ref_img_urls' => [],
+                    // 计费锚点(动漫/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -15561,7 +15565,10 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             try {
                 $params = [
                     'prompt' => $description,
-                    'ref_img_urls' => []
+                    'ref_img_urls' => [],
+                    // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
+                    'episode_number' => $episode_number,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -15650,7 +15657,10 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             try {
                 $params = [
                     'prompt' => $description,
-                    'ref_img_urls' => []
+                    'ref_img_urls' => [],
+                    // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
+                    'episode_number' => $episode_number,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);
@@ -15739,7 +15749,10 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             try {
                 $params = [
                     'prompt' => $description,
-                    'ref_img_urls' => []
+                    'ref_img_urls' => [],
+                    // 计费锚点(动漫/剧集/剧本),用于积分明细归属统计
+                    'anime_id' => $anime_id,
+                    'episode_number' => $episode_number,
                 ];
                 
                 $task = $this->aiImageGenerationService->createImageGenerationTask($params);

+ 111 - 6
app/Services/PointsService.php

@@ -4,6 +4,8 @@ namespace App\Services;
 
 use App\Consts\ErrorConst;
 use App\Facade\Site;
+use App\Libs\PointsDisplay;
+use App\Libs\ScriptAnchor;
 use App\Libs\Utils;
 use App\Models\MpGeneratePicTask;
 use App\Models\MpGenerateVideoTask;
@@ -633,7 +635,9 @@ class PointsService
     /**
      * 导出积分明细 CSV(与列表筛选条件一致:uid/type/start_date/end_date)
      *
-     * 列:类型、模型、积分变动、变动前、变动后、Token、备注、创建时间
+     * 列:账号、类型、模型、分辨率、动漫/剧本、积分变动、变动前、变动后、Token、备注、创建时间
+     * 模型名做展示映射(隐藏百度/快快等供应商字样),分辨率按类型展示(视频 480p/超分720p、图片 宽*高)
+     * 名称列取「动漫名优先,其次剧本名」
      * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据)
      *
      * @param array $params
@@ -650,7 +654,7 @@ class PointsService
             ->orderBy('d.id', 'desc')
             ->get();
 
-        $headers = ['账号', '类型', '模型', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
+        $headers = ['账号', '类型', '模型', '分辨率', '动漫/剧本', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
         $csvRows = [];
         $totalConsumed = 0;
         $totalRefunded = 0;
@@ -680,7 +684,10 @@ class PointsService
             $csvRows[] = [
                 (string)getProp($row, 'account', ''),
                 PointsTransformer::TYPE_LABELS[$typeCode] ?? $typeCode,
-                (string)getProp($chargeInfo, 'model', ''),
+                PointsDisplay::modelName((string)getProp($chargeInfo, 'model', '')),
+                PointsDisplay::resolution($typeCode, $chargeInfo),
+                // 名称:动漫名优先,其次剧本名
+                (string)(getProp($row, 'anime_name', '') !== '' ? getProp($row, 'anime_name', '') : getProp($row, 'script_name', '')),
                 $pointsChange,
                 (string)(float)getProp($row, 'points_before', 0),
                 (string)(float)getProp($row, 'points_after', 0),
@@ -690,8 +697,12 @@ class PointsService
             ];
         }
 
-        // 总计(最后一行单行展示)
-        $csvRows[] = ['总计', '', '', '', '', '', '', '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded, ''];
+        // 总计(最后一行单行展示,列数与表头对齐:备注列放汇总文案)
+        $csvRows[] = [
+            '总计', '', '', '', '', '', '', '', '',
+            '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded,
+            '',
+        ];
 
         $start = $startDate ?: date('Y-m-d');
         $end = $endDate ?: date('Y-m-d');
@@ -719,7 +730,10 @@ class PointsService
     }
 
     /**
-     * 构建积分明细查询(权限范围 + uid/type/日期/nickname 筛选)。
+     * 构建积分明细查询(权限范围 + uid/type/日期/nickname/名称 筛选)。
+     *
+     * 名称筛选:anime_name,按「动漫名优先,其次剧本名」匹配(COALESCE(anime_name, script_name)),
+     * 与展示字段 anime_name 口径一致;兼容旧的 script_name / name 入参(行为相同)。
      *
      * @param array $params
      * @return array [查询构建器, 生效的 uid(0 表示多用户视角)]
@@ -764,6 +778,18 @@ class PointsService
             $base->where('d.type', $type);
         }
 
+        // 名称模糊搜索(anime_name):按「动漫名优先,其次剧本名」匹配(与动漫/剧本无关的记录两者为空,不会命中)
+        $nameKeyword = trim((string)getProp($params, 'anime_name', ''));
+        if ($nameKeyword === '') {
+            $nameKeyword = trim((string)getProp($params, 'script_name', ''));
+        }
+        if ($nameKeyword === '') {
+            $nameKeyword = trim((string)getProp($params, 'name', ''));
+        }
+        if ($nameKeyword !== '') {
+            $base->whereRaw('COALESCE(d.anime_name, d.script_name) LIKE ?', ['%' . $nameKeyword . '%']);
+        }
+
         $startDate = getProp($params, 'start_date', '');
         if ($startDate) {
             $base->where('d.created_at', '>=', $startDate . ' 00:00:00');
@@ -1488,9 +1514,15 @@ class PointsService
                 $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
             }
 
+            $scriptContext = $this->resolveScriptContext($chargeInfo, null, $type);
+
             DB::table('mp_user_points_details')->insert([
                 'uid' => $uid,
                 'cpid' => $cpid,
+                'anime_id' => $scriptContext['anime_id'],
+                'anime_name' => $scriptContext['anime_name'],
+                'script_id' => $scriptContext['script_id'],
+                'script_name' => $scriptContext['script_name'],
                 'task_id' => null,
                 'type' => $type,
                 'api_type' => $apiType,
@@ -1635,10 +1667,17 @@ class PointsService
                 ]);
             }
 
+            // 剧本维度(解析失败或与剧本无关时为空,不影响计费主流程)
+            $scriptContext = $this->resolveScriptContext($chargeInfo, $taskId, $type);
+
             // 记录积分使用明细
             DB::table('mp_user_points_details')->insert([
                 'uid' => $uid,
                 'cpid' => (int)getProp($user, 'cpid', 0),
+                'anime_id' => $scriptContext['anime_id'],
+                'anime_name' => $scriptContext['anime_name'],
+                'script_id' => $scriptContext['script_id'],
+                'script_name' => $scriptContext['script_name'],
                 'task_id' => $taskId,
                 'type' => $type,
                 'api_type' => $apiType,
@@ -1707,4 +1746,70 @@ class PointsService
 
         return mb_strpos($e->getMessage(), 'Duplicate entry') !== false;
     }
+
+    /**
+     * 解析积分明细归属的动漫与剧本(anime_id + 剧本ID + 剧本名)
+     *
+     * 解析顺序(任一命中即返回):
+     * 1. charge_info 中的动漫/剧集/剧幕/分镜锚点(ScriptAnchor 解析,含从 alias_act_id、alias_segment_id 反查)
+     *    → 剧本优先取动漫绑定的剧本(mp_animes.script_id)
+     * 2. charge_info.script_id(动漫未绑定剧本时回退,AI对话等场景直接带剧本ID)
+     * 3. charge_info.group_id → mp_script_episode_group.script_id(剧本分集分组)
+     * 4. 任务表(视频/图片)的 alias_act_id / alias_segment_id(兼容未写锚点的历史任务)
+     *
+     * 与剧本无关(积分发放、无剧本上下文的对话等)或解析异常时返回空,
+     * 动漫/剧本字段属于附加维度,任何异常都不能影响计费主流程。
+     *
+     * @param array    $chargeInfo 计费信息
+     * @param int|null $taskId     关联任务ID(视频/图片任务)
+     * @param string   $type       明细类型(video/image/chat/system/company)
+     * @return array ['anime_id' => int|null, 'anime_name' => string|null, 'script_id' => int|null, 'script_name' => string|null]
+     */
+    public function resolveScriptContext(array $chargeInfo, ?int $taskId = null, string $type = ''): array
+    {
+        $empty = ['anime_id' => null, 'anime_name' => null, 'script_id' => null, 'script_name' => null];
+
+        try {
+            // 锚点解析:动漫/剧集/剧幕/分镜 + 剧本(动漫绑定剧本优先,其次上下文剧本ID)
+            $anchor = ScriptAnchor::resolve($chargeInfo);
+            $animeId = (int)$anchor['anime_id'];
+            $scriptId = (int)$anchor['script_id'];
+
+            // 动漫/剧集上下文缺失时,回查任务表保存的关联片段(历史数据兼容)
+            if ($animeId <= 0 && $taskId) {
+                $taskAnchor = ScriptAnchor::anchorByTask($type, (int)$taskId);
+                $animeId = (int)$taskAnchor['anime_id'];
+                if ($scriptId <= 0) {
+                    $scriptId = (int)$taskAnchor['script_id'];
+                }
+            }
+
+            // 剧本仍未确定:按剧本分集分组回查
+            if ($scriptId <= 0) {
+                $groupId = (int)getProp($chargeInfo, 'group_id', 0);
+                if ($groupId > 0) {
+                    $scriptId = ScriptAnchor::scriptIdByGroup($groupId);
+                }
+            }
+
+            $scriptName = $scriptId > 0 ? ScriptAnchor::scriptNameById($scriptId) : '';
+            $animeName = $animeId > 0 ? ScriptAnchor::animeNameById($animeId) : '';
+
+            return [
+                'anime_id' => $animeId > 0 ? $animeId : null,
+                'anime_name' => $animeName !== '' ? $animeName : null,
+                // 剧本未知或已不存在时只保留动漫维度
+                'script_id' => $scriptName !== '' ? $scriptId : null,
+                'script_name' => $scriptName !== '' ? $scriptName : null,
+            ];
+
+        } catch (\Throwable $e) {
+            dLog('points')->warning('动漫/剧本信息解析失败(不影响计费): ' . $e->getMessage(), [
+                'task_id' => $taskId,
+                'type' => $type,
+            ]);
+
+            return $empty;
+        }
+    }
 }

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

@@ -2,6 +2,8 @@
 
 namespace App\Transformer\Points;
 
+use App\Libs\PointsDisplay;
+
 class PointsTransformer
 {
     const TYPE_LABELS = [
@@ -32,6 +34,11 @@ class PointsTransformer
     /**
      * 单条积分流水
      *
+     * 模型名统一做展示映射(隐藏百度/快快等供应商字样);
+     * 分辨率按类型展示:视频 480p/720p/1080p/4k 与 超分720p/超分1080p,图片 实际宽*高,其余为空。
+     * 名称字段只返回一个 anime_name:动漫名优先,其次剧本名(与动漫/剧本无关时为空)。
+     * 模型名保持原键名 charge_info.model 不变,值改为展示名(隐藏供应商字样),前端无需调整取数位置。
+     *
      * @param $list
      * @return array
      */
@@ -48,6 +55,21 @@ class PointsTransformer
             $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
 
             $type = getProp($item, 'type', '');
+            $apiType = (string)getProp($item, 'api_type', '');
+            $rawModel = (string)getProp($chargeInfo, 'model', '');
+            $modelName = PointsDisplay::modelName($rawModel);
+            $resolution = PointsDisplay::resolution($type, $chargeInfo);
+
+            // 名称只保留一个:动漫名优先,其次剧本名
+            $animeName = trim((string)getProp($item, 'anime_name', ''));
+            $scriptName = trim((string)getProp($item, 'script_name', ''));
+            $displayName = $animeName !== '' ? $animeName : $scriptName;
+
+            // 展示层隐藏供应商字样(库中原始 charge_info 不变)
+            if ($rawModel !== '' && $modelName !== '') {
+                $chargeInfo['model'] = $modelName;
+            }
+
             $pointsConsumed = (float)getProp($item, 'points_consumed', 0);
             if ($pointsConsumed > 0) {
                 // 消耗:带负号
@@ -63,8 +85,12 @@ class PointsTransformer
                 'type' => $type,
                 'type_label' => self::TYPE_LABELS[$type] ?? $type,
                 'account' => (string)getProp($item, 'account', ''),
-                'api_type' => getProp($item, 'api_type'),
+                'api_type' => $apiType,
                 'task_id' => (int)getProp($item, 'task_id'),
+                'resolution' => $resolution,
+                'anime_id' => (int)getProp($item, 'anime_id', 0),
+                'script_id' => (int)getProp($item, 'script_id', 0),
+                'anime_name' => $displayName,
                 'charge_info' => $chargeInfo,
                 'points_consumed' => $pointsConsumed,
                 'points_change' => $pointsChange,