ScriptAnchor.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <?php
  2. namespace App\Libs;
  3. use App\Models\MpUserPointsDetail;
  4. use Illuminate\Support\Facades\DB;
  5. /**
  6. * 积分明细锚点解析
  7. *
  8. * 统一从任务/计费上下文推导「动漫 → 剧集 → 剧幕/分镜 → 剧本」锚点,用于:
  9. * - 任务创建时写入 charge_info(AIVideoGenerationService / AIImageGenerationService),
  10. * 写入锚点:anime_id / episode_id / episode_number / alias_act_id / alias_segment_id / script_id,
  11. * 其中剧幕/分镜以 alias_ 前缀记录,其余展示字段不入 charge_info;
  12. * - 积分明细落库时写入 anime_id / anime_name / episode_number / script_id / script_name(PointsService)。
  13. *
  14. * 上下文键名兼容两类写法:
  15. * - 标准锚点:anime_id / episode_id / episode_number / act_id / segment_id / script_id / group_id
  16. * - 历史参数:alias_act_id(mp_episode_segments.id)、alias_segment_id(mp_episode_segments.segment_id / 分镜号)
  17. *
  18. * 规则:
  19. * - 有分镜行ID或分镜号时,回查 mp_episode_segments 补全动漫、剧集、剧幕/分镜锚点;
  20. * - 剧本优先取动漫绑定的剧本(mp_animes.script_id),其次取上下文显式传入的 script_id;
  21. * - 推导不到的一律返回 0 / 空串,异常只记日志,绝不影响任务创建与计费主流程。
  22. */
  23. class ScriptAnchor
  24. {
  25. /**
  26. * 分镜表读取字段(只取锚点链路需要的列)
  27. */
  28. const SEGMENT_COLUMNS = [
  29. 'id',
  30. 'anime_id',
  31. 'episode_id',
  32. 'episode_number',
  33. 'segment_id',
  34. ];
  35. /** @var array 分镜行ID => 行数据 */
  36. private static $segmentRowCache = [];
  37. /** @var array 分镜号 => 行数据 */
  38. private static $segmentNoCache = [];
  39. /** @var array 动漫ID => 动漫行数据(anime_name、script_id) */
  40. private static $animeRowCache = [];
  41. /** @var array 剧本分集分组ID => 剧本ID */
  42. private static $groupScriptCache = [];
  43. /** @var array 剧本ID => 剧本名 */
  44. private static $scriptNameCache = [];
  45. /** @var array 动漫ID-集数 => 分集ID */
  46. private static $episodeIdCache = [];
  47. /** @var array 分集ID => 分集行数据 */
  48. private static $episodeRowCache = [];
  49. /** @var array 任务表锚点缓存 */
  50. private static $taskAnchorCache = [];
  51. /**
  52. * 空锚点(归属锚点:动漫 / 剧集 / 分集序号 / 剧幕 / 分镜 / 剧本)
  53. *
  54. * @return array
  55. */
  56. public static function emptyAnchor(): array
  57. {
  58. return [
  59. 'anime_id' => 0,
  60. 'episode_id' => 0,
  61. 'episode_number' => 0,
  62. 'act_id' => 0,
  63. 'segment_id' => '',
  64. 'script_id' => 0,
  65. ];
  66. }
  67. /**
  68. * 由上下文(任务创建参数或 charge_info)解析完整锚点
  69. *
  70. * @param array $context
  71. * @return array 见 emptyAnchor(),缺失项为 0 / 空串
  72. */
  73. public static function resolve(array $context): array
  74. {
  75. $anchor = self::emptyAnchor();
  76. try {
  77. // 1. 上下文显式传入的锚点
  78. $animeId = (int)getProp($context, 'anime_id', 0);
  79. $episodeId = (int)getProp($context, 'episode_id', 0);
  80. $episodeNumber = (int)getProp($context, 'episode_number', 0);
  81. $scriptId = (int)getProp($context, 'script_id', 0);
  82. $actId = (int)getProp($context, 'act_id', 0);
  83. if ($actId <= 0) {
  84. $actId = (int)getProp($context, 'alias_act_id', 0);
  85. }
  86. $segmentId = self::normalizeSegmentNo(getProp($context, 'segment_id', ''));
  87. if ($segmentId === '') {
  88. $segmentId = self::normalizeSegmentNo(getProp($context, 'alias_segment_id', ''));
  89. }
  90. // 2. 由分镜行/分镜号补全动漫、剧集、剧幕/分镜
  91. $segmentRow = [];
  92. if ($actId > 0) {
  93. $segmentRow = self::fromSegmentRowId($actId);
  94. } elseif ($segmentId !== '') {
  95. $segmentRow = self::fromSegmentNo($segmentId);
  96. }
  97. if (!empty($segmentRow)) {
  98. $animeId = $animeId ?: (int)$segmentRow['anime_id'];
  99. $episodeId = $episodeId ?: (int)$segmentRow['episode_id'];
  100. $episodeNumber = $episodeNumber ?: (int)$segmentRow['episode_number'];
  101. $actId = (int)$segmentRow['id'];
  102. if ($segmentId === '') {
  103. $segmentId = (string)$segmentRow['segment_id'];
  104. }
  105. }
  106. // 3. 只有动漫 + 集数时补全分集ID
  107. if ($episodeId <= 0 && $animeId > 0 && $episodeNumber > 0) {
  108. $episodeId = self::episodeIdByNumber($animeId, $episodeNumber);
  109. }
  110. // 4. 只有分集ID时补全动漫与分集序号(用于取动漫绑定的剧本、回填积分明细分集)
  111. if ($episodeId > 0 && ($animeId <= 0 || $episodeNumber <= 0)) {
  112. $episodeRow = self::fromEpisodeId($episodeId);
  113. if ($episodeRow) {
  114. $animeId = $animeId ?: (int)$episodeRow['anime_id'];
  115. $episodeNumber = $episodeNumber ?: (int)$episodeRow['episode_number'];
  116. }
  117. }
  118. // 5. 剧本:动漫绑定优先,其次上下文脚本ID
  119. $animeScriptId = $animeId > 0 ? self::scriptIdByAnime($animeId) : 0;
  120. $scriptId = $animeScriptId > 0 ? $animeScriptId : $scriptId;
  121. $anchor['anime_id'] = $animeId;
  122. $anchor['episode_id'] = $episodeId;
  123. $anchor['episode_number'] = $episodeNumber;
  124. $anchor['act_id'] = $actId;
  125. $anchor['segment_id'] = $segmentId;
  126. $anchor['script_id'] = $scriptId;
  127. return $anchor;
  128. } catch (\Throwable $e) {
  129. dLog('points')->warning('锚点解析失败(不影响主流程): ' . $e->getMessage());
  130. return $anchor;
  131. }
  132. }
  133. /**
  134. * 将锚点合并进 charge_info
  135. *
  136. * - act_id / segment_id 统一按 alias_ 前缀记录:alias_act_id / alias_segment_id;
  137. * - episode_number 与其它锚点一样写入 charge_info;
  138. * - charge_info 中已有的值优先(尤其任务参数已带的 alias_act_id / alias_segment_id),缺失的才补充;
  139. * - 未解析到的锚点不写入,避免冗余空值。
  140. *
  141. * @param array $chargeInfo 已构建好的计费信息
  142. * @param array $context 任务创建参数 / 计费上下文
  143. * @return array 合并后的计费信息
  144. */
  145. public static function mergeIntoChargeInfo(array $chargeInfo, array $context): array
  146. {
  147. $anchor = self::resolve($context);
  148. // act_id / segment_id 统一转换成 alias_ 前缀
  149. $anchor['alias_act_id'] = $anchor['act_id'];
  150. $anchor['alias_segment_id'] = $anchor['segment_id'];
  151. unset($anchor['act_id'], $anchor['segment_id']);
  152. foreach ($anchor as $key => $value) {
  153. if ($value === 0 || $value === '' || $value === null) {
  154. continue;
  155. }
  156. // 已有值优先(alias_ 开头的历史字段保留原值)
  157. if (!empty(getProp($chargeInfo, $key, ''))) {
  158. continue;
  159. }
  160. $chargeInfo[$key] = $value;
  161. }
  162. return $chargeInfo;
  163. }
  164. /**
  165. * 用已解析出的锚点补写 charge_info(已有值优先,补 anime_id / episode_number / script_id)
  166. *
  167. * 用于积分明细落库时(PointsService):保证明细列与 charge_info 口径一致,
  168. * 已经写入过锚点的任务(视频/图片)不会重复写入。
  169. *
  170. * @param array $chargeInfo
  171. * @param int|null $animeId
  172. * @param int|null $scriptId
  173. * @param int|null $episodeNumber 分集序号(与分集无关时传 0)
  174. * @return array
  175. */
  176. public static function fillChargeInfoAnchors(array $chargeInfo, $animeId, $scriptId, $episodeNumber = 0): array
  177. {
  178. if ((int)$animeId > 0 && empty(getProp($chargeInfo, 'anime_id', ''))) {
  179. $chargeInfo['anime_id'] = (int)$animeId;
  180. }
  181. if ((int)$episodeNumber > 0 && empty(getProp($chargeInfo, 'episode_number', ''))) {
  182. $chargeInfo['episode_number'] = (int)$episodeNumber;
  183. }
  184. if ((int)$scriptId > 0 && empty(getProp($chargeInfo, 'script_id', ''))) {
  185. $chargeInfo['script_id'] = (int)$scriptId;
  186. }
  187. return $chargeInfo;
  188. }
  189. /**
  190. * 从请求参数提取计费锚点与业务来源标记(前端有传才记录)
  191. *
  192. * 支持字段:
  193. * - 锚点:anime_id / episode_id / episode_number / script_id / alias_act_id / alias_segment_id
  194. * - 来源标记:source_from(0 默认 / 1 资产 / 2 画布)→ 写入 charge_info.source_from,
  195. * (charge_info.source 已被 act/segment/generateText 等计费场景标记占用,因此前端字段统一用 source_from)
  196. *
  197. * @param array $data 请求参数
  198. * @return array
  199. */
  200. public static function chargeContextFromRequest($data): array
  201. {
  202. $context = [];
  203. foreach (['anime_id', 'episode_id', 'episode_number', 'script_id', 'alias_act_id'] as $key) {
  204. $value = (int)getProp($data, $key, 0);
  205. if ($value > 0) {
  206. $context[$key] = $value;
  207. }
  208. }
  209. // 分镜号是字符串(非纯数字),单独处理
  210. $segmentNo = trim((string)getProp($data, 'alias_segment_id', ''));
  211. if ($segmentNo !== '' && $segmentNo !== '0') {
  212. $context['alias_segment_id'] = $segmentNo;
  213. }
  214. // 业务来源:0 默认 / 1 资产 / 2 画布(前端字段 source_from)
  215. $context['source_from'] = (int)getProp($data, 'source_from', 0);
  216. return $context;
  217. }
  218. /**
  219. * 分镜行ID(mp_episode_segments.id)→ 分镜行数据
  220. *
  221. * @param int $rowId
  222. * @return array
  223. */
  224. public static function fromSegmentRowId(int $rowId): array
  225. {
  226. if ($rowId <= 0) {
  227. return [];
  228. }
  229. if (!array_key_exists($rowId, self::$segmentRowCache)) {
  230. $row = DB::table('mp_episode_segments')->where('id', $rowId)->first(self::SEGMENT_COLUMNS);
  231. self::$segmentRowCache[$rowId] = $row ? (array)$row : [];
  232. }
  233. return self::$segmentRowCache[$rowId];
  234. }
  235. /**
  236. * 分镜号(mp_episode_segments.segment_id)→ 分镜行数据
  237. *
  238. * @param string $segmentNo
  239. * @return array
  240. */
  241. public static function fromSegmentNo(string $segmentNo): array
  242. {
  243. $segmentNo = self::normalizeSegmentNo($segmentNo);
  244. if ($segmentNo === '') {
  245. return [];
  246. }
  247. if (!array_key_exists($segmentNo, self::$segmentNoCache)) {
  248. $row = DB::table('mp_episode_segments')->where('segment_id', $segmentNo)->first(self::SEGMENT_COLUMNS);
  249. self::$segmentNoCache[$segmentNo] = $row ? (array)$row : [];
  250. }
  251. return self::$segmentNoCache[$segmentNo];
  252. }
  253. /**
  254. * 视频/图片任务ID → 锚点(回查任务表保存的关联片段,兼容未写锚点的历史任务)
  255. *
  256. * @param string $type 明细类型(video/image)
  257. * @param int $taskId 任务表主键
  258. * @return array
  259. */
  260. public static function anchorByTask(string $type, int $taskId): array
  261. {
  262. if ($taskId <= 0 || !in_array($type, [MpUserPointsDetail::TYPE_VIDEO, MpUserPointsDetail::TYPE_IMAGE], true)) {
  263. return self::emptyAnchor();
  264. }
  265. $cacheKey = $type . ':' . $taskId;
  266. if (!array_key_exists($cacheKey, self::$taskAnchorCache)) {
  267. $table = $type === MpUserPointsDetail::TYPE_VIDEO ? 'mp_generate_video_tasks' : 'mp_generate_pic_tasks';
  268. $task = DB::table($table)->where('id', $taskId)->first(['alias_act_id', 'alias_segment_id']);
  269. $anchor = self::emptyAnchor();
  270. if ($task) {
  271. $context = [];
  272. if ((int)getProp($task, 'alias_act_id', 0) > 0) {
  273. $context['alias_act_id'] = (int)getProp($task, 'alias_act_id', 0);
  274. }
  275. $segmentNo = self::normalizeSegmentNo(getProp($task, 'alias_segment_id', ''));
  276. if ($segmentNo !== '') {
  277. $context['alias_segment_id'] = $segmentNo;
  278. }
  279. if ($context) {
  280. $anchor = self::resolve($context);
  281. }
  282. }
  283. self::$taskAnchorCache[$cacheKey] = $anchor;
  284. }
  285. return self::$taskAnchorCache[$cacheKey];
  286. }
  287. /**
  288. * 动漫ID → 绑定剧本ID(未绑定返回 0)
  289. *
  290. * @param int $animeId
  291. * @return int
  292. */
  293. public static function scriptIdByAnime(int $animeId): int
  294. {
  295. $animeRow = self::animeRowById($animeId);
  296. return (int)($animeRow['script_id'] ?? 0);
  297. }
  298. /**
  299. * 动漫ID → 动漫名(动漫不存在返回空串)
  300. *
  301. * @param int $animeId
  302. * @return string
  303. */
  304. public static function animeNameById(int $animeId): string
  305. {
  306. $animeRow = self::animeRowById($animeId);
  307. return trim((string)($animeRow['anime_name'] ?? ''));
  308. }
  309. /**
  310. * 动漫ID → 动漫行数据
  311. *
  312. * @param int $animeId
  313. * @return array
  314. */
  315. private static function animeRowById(int $animeId): array
  316. {
  317. if ($animeId <= 0) {
  318. return [];
  319. }
  320. if (!array_key_exists($animeId, self::$animeRowCache)) {
  321. $row = DB::table('mp_animes')->where('id', $animeId)->first(['id', 'anime_name', 'script_id']);
  322. self::$animeRowCache[$animeId] = $row ? (array)$row : [];
  323. }
  324. return self::$animeRowCache[$animeId];
  325. }
  326. /**
  327. * 剧本分集分组ID(mp_script_episode_group.id)→ 剧本ID
  328. *
  329. * @param int $groupId
  330. * @return int
  331. */
  332. public static function scriptIdByGroup(int $groupId): int
  333. {
  334. if ($groupId <= 0) {
  335. return 0;
  336. }
  337. if (!array_key_exists($groupId, self::$groupScriptCache)) {
  338. self::$groupScriptCache[$groupId] = (int)DB::table('mp_script_episode_group')->where('id', $groupId)->value('script_id');
  339. }
  340. return (int)self::$groupScriptCache[$groupId];
  341. }
  342. /**
  343. * 剧本ID → 剧本名(剧本不存在返回空串)
  344. *
  345. * @param int $scriptId
  346. * @return string
  347. */
  348. public static function scriptNameById(int $scriptId): string
  349. {
  350. if ($scriptId <= 0) {
  351. return '';
  352. }
  353. if (!array_key_exists($scriptId, self::$scriptNameCache)) {
  354. self::$scriptNameCache[$scriptId] = trim((string)DB::table('mp_scripts')->where('id', $scriptId)->value('script_name'));
  355. }
  356. return (string)self::$scriptNameCache[$scriptId];
  357. }
  358. /**
  359. * 动漫ID + 集数 → 分集ID
  360. *
  361. * @param int $animeId
  362. * @param int $episodeNumber
  363. * @return int
  364. */
  365. public static function episodeIdByNumber(int $animeId, int $episodeNumber): int
  366. {
  367. if ($animeId <= 0 || $episodeNumber <= 0) {
  368. return 0;
  369. }
  370. $cacheKey = $animeId . '-' . $episodeNumber;
  371. if (!array_key_exists($cacheKey, self::$episodeIdCache)) {
  372. self::$episodeIdCache[$cacheKey] = (int)DB::table('mp_anime_episodes')
  373. ->where('anime_id', $animeId)
  374. ->where('episode_number', $episodeNumber)
  375. ->value('id');
  376. }
  377. return (int)self::$episodeIdCache[$cacheKey];
  378. }
  379. /**
  380. * 分集ID → 分集行数据(mp_anime_episodes)
  381. *
  382. * @param int $episodeId
  383. * @return array
  384. */
  385. public static function fromEpisodeId(int $episodeId): array
  386. {
  387. if ($episodeId <= 0) {
  388. return [];
  389. }
  390. if (!array_key_exists($episodeId, self::$episodeRowCache)) {
  391. $row = DB::table('mp_anime_episodes')->where('id', $episodeId)->first(['id', 'anime_id', 'episode_number']);
  392. self::$episodeRowCache[$episodeId] = $row ? (array)$row : [];
  393. }
  394. return self::$episodeRowCache[$episodeId];
  395. }
  396. /**
  397. * 归一化分镜号:空、0、字符串 "0" 均视为未传
  398. *
  399. * @param mixed $segmentNo
  400. * @return string
  401. */
  402. private static function normalizeSegmentNo($segmentNo): string
  403. {
  404. $segmentNo = trim((string)$segmentNo);
  405. if ($segmentNo === '' || $segmentNo === '0') {
  406. return '';
  407. }
  408. return $segmentNo;
  409. }
  410. }