ScriptAnchor.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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. * 只写入 ID 锚点:anime_id / episode_id / act_id(= alias_act_id)/ segment_id(= alias_segment_id)/ script_id,
  11. * 剧集序号、剧幕序号、分镜序号等展示字段不入 charge_info;
  12. * - 积分明细落库时写入 anime_id / anime_name / 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. * 空锚点(仅记录归属所需的 ID 锚点,编号等展示字段不入 charge_info)
  53. *
  54. * @return array
  55. */
  56. public static function emptyAnchor(): array
  57. {
  58. return [
  59. 'anime_id' => 0,
  60. 'episode_id' => 0,
  61. 'act_id' => 0,
  62. 'segment_id' => '',
  63. 'script_id' => 0,
  64. ];
  65. }
  66. /**
  67. * 由上下文(任务创建参数或 charge_info)解析完整锚点
  68. *
  69. * @param array $context
  70. * @return array 见 emptyAnchor(),缺失项为 0 / 空串
  71. */
  72. public static function resolve(array $context): array
  73. {
  74. $anchor = self::emptyAnchor();
  75. try {
  76. // 1. 上下文显式传入的锚点
  77. $animeId = (int)getProp($context, 'anime_id', 0);
  78. $episodeId = (int)getProp($context, 'episode_id', 0);
  79. $episodeNumber = (int)getProp($context, 'episode_number', 0);
  80. $scriptId = (int)getProp($context, 'script_id', 0);
  81. $actId = (int)getProp($context, 'act_id', 0);
  82. if ($actId <= 0) {
  83. $actId = (int)getProp($context, 'alias_act_id', 0);
  84. }
  85. $segmentId = self::normalizeSegmentNo(getProp($context, 'segment_id', ''));
  86. if ($segmentId === '') {
  87. $segmentId = self::normalizeSegmentNo(getProp($context, 'alias_segment_id', ''));
  88. }
  89. // 2. 由分镜行/分镜号补全动漫、剧集、剧幕/分镜
  90. $segmentRow = [];
  91. if ($actId > 0) {
  92. $segmentRow = self::fromSegmentRowId($actId);
  93. } elseif ($segmentId !== '') {
  94. $segmentRow = self::fromSegmentNo($segmentId);
  95. }
  96. if (!empty($segmentRow)) {
  97. $animeId = $animeId ?: (int)$segmentRow['anime_id'];
  98. $episodeId = $episodeId ?: (int)$segmentRow['episode_id'];
  99. $episodeNumber = $episodeNumber ?: (int)$segmentRow['episode_number'];
  100. $actId = (int)$segmentRow['id'];
  101. if ($segmentId === '') {
  102. $segmentId = (string)$segmentRow['segment_id'];
  103. }
  104. }
  105. // 3. 只有动漫 + 集数时补全分集ID
  106. if ($episodeId <= 0 && $animeId > 0 && $episodeNumber > 0) {
  107. $episodeId = self::episodeIdByNumber($animeId, $episodeNumber);
  108. }
  109. // 4. 只有分集ID时补全动漫(用于取动漫绑定的剧本)
  110. if ($episodeId > 0 && ($animeId <= 0 || $episodeNumber <= 0)) {
  111. $episodeRow = self::fromEpisodeId($episodeId);
  112. if ($episodeRow) {
  113. $animeId = $animeId ?: (int)$episodeRow['anime_id'];
  114. }
  115. }
  116. // 5. 剧本:动漫绑定优先,其次上下文脚本ID
  117. $animeScriptId = $animeId > 0 ? self::scriptIdByAnime($animeId) : 0;
  118. $scriptId = $animeScriptId > 0 ? $animeScriptId : $scriptId;
  119. $anchor['anime_id'] = $animeId;
  120. $anchor['episode_id'] = $episodeId;
  121. $anchor['act_id'] = $actId;
  122. $anchor['segment_id'] = $segmentId;
  123. $anchor['script_id'] = $scriptId;
  124. return $anchor;
  125. } catch (\Throwable $e) {
  126. dLog('points')->warning('锚点解析失败(不影响主流程): ' . $e->getMessage());
  127. return $anchor;
  128. }
  129. }
  130. /**
  131. * 将锚点合并进 charge_info
  132. *
  133. * - act_id / segment_id 统一按 alias_ 前缀记录:alias_act_id / alias_segment_id;
  134. * - charge_info 中已有的值优先(尤其任务参数已带的 alias_act_id / alias_segment_id),缺失的才补充;
  135. * - 未解析到的锚点不写入,避免冗余空值。
  136. *
  137. * @param array $chargeInfo 已构建好的计费信息
  138. * @param array $context 任务创建参数 / 计费上下文
  139. * @return array 合并后的计费信息
  140. */
  141. public static function mergeIntoChargeInfo(array $chargeInfo, array $context): array
  142. {
  143. $anchor = self::resolve($context);
  144. // act_id / segment_id 统一转换成 alias_ 前缀
  145. $anchor['alias_act_id'] = $anchor['act_id'];
  146. $anchor['alias_segment_id'] = $anchor['segment_id'];
  147. unset($anchor['act_id'], $anchor['segment_id']);
  148. foreach ($anchor as $key => $value) {
  149. if ($value === 0 || $value === '' || $value === null) {
  150. continue;
  151. }
  152. // 已有值优先(alias_ 开头的历史字段保留原值)
  153. if (!empty(getProp($chargeInfo, $key, ''))) {
  154. continue;
  155. }
  156. $chargeInfo[$key] = $value;
  157. }
  158. return $chargeInfo;
  159. }
  160. /**
  161. * 用已解析出的锚点补写 charge_info(已有值优先,仅补 anime_id / script_id)
  162. *
  163. * 用于积分明细落库时(PointsService):保证明细列与 charge_info 口径一致,
  164. * 已经写入过锚点的任务(视频/图片)不会重复写入。
  165. *
  166. * @param array $chargeInfo
  167. * @param int|null $animeId
  168. * @param int|null $scriptId
  169. * @return array
  170. */
  171. public static function fillChargeInfoAnchors(array $chargeInfo, $animeId, $scriptId): array
  172. {
  173. if ((int)$animeId > 0 && empty(getProp($chargeInfo, 'anime_id', ''))) {
  174. $chargeInfo['anime_id'] = (int)$animeId;
  175. }
  176. if ((int)$scriptId > 0 && empty(getProp($chargeInfo, 'script_id', ''))) {
  177. $chargeInfo['script_id'] = (int)$scriptId;
  178. }
  179. return $chargeInfo;
  180. }
  181. /**
  182. * 分镜行ID(mp_episode_segments.id)→ 分镜行数据
  183. *
  184. * @param int $rowId
  185. * @return array
  186. */
  187. public static function fromSegmentRowId(int $rowId): array
  188. {
  189. if ($rowId <= 0) {
  190. return [];
  191. }
  192. if (!array_key_exists($rowId, self::$segmentRowCache)) {
  193. $row = DB::table('mp_episode_segments')->where('id', $rowId)->first(self::SEGMENT_COLUMNS);
  194. self::$segmentRowCache[$rowId] = $row ? (array)$row : [];
  195. }
  196. return self::$segmentRowCache[$rowId];
  197. }
  198. /**
  199. * 分镜号(mp_episode_segments.segment_id)→ 分镜行数据
  200. *
  201. * @param string $segmentNo
  202. * @return array
  203. */
  204. public static function fromSegmentNo(string $segmentNo): array
  205. {
  206. $segmentNo = self::normalizeSegmentNo($segmentNo);
  207. if ($segmentNo === '') {
  208. return [];
  209. }
  210. if (!array_key_exists($segmentNo, self::$segmentNoCache)) {
  211. $row = DB::table('mp_episode_segments')->where('segment_id', $segmentNo)->first(self::SEGMENT_COLUMNS);
  212. self::$segmentNoCache[$segmentNo] = $row ? (array)$row : [];
  213. }
  214. return self::$segmentNoCache[$segmentNo];
  215. }
  216. /**
  217. * 视频/图片任务ID → 锚点(回查任务表保存的关联片段,兼容未写锚点的历史任务)
  218. *
  219. * @param string $type 明细类型(video/image)
  220. * @param int $taskId 任务表主键
  221. * @return array
  222. */
  223. public static function anchorByTask(string $type, int $taskId): array
  224. {
  225. if ($taskId <= 0 || !in_array($type, [MpUserPointsDetail::TYPE_VIDEO, MpUserPointsDetail::TYPE_IMAGE], true)) {
  226. return self::emptyAnchor();
  227. }
  228. $cacheKey = $type . ':' . $taskId;
  229. if (!array_key_exists($cacheKey, self::$taskAnchorCache)) {
  230. $table = $type === MpUserPointsDetail::TYPE_VIDEO ? 'mp_generate_video_tasks' : 'mp_generate_pic_tasks';
  231. $task = DB::table($table)->where('id', $taskId)->first(['alias_act_id', 'alias_segment_id']);
  232. $anchor = self::emptyAnchor();
  233. if ($task) {
  234. $context = [];
  235. if ((int)getProp($task, 'alias_act_id', 0) > 0) {
  236. $context['alias_act_id'] = (int)getProp($task, 'alias_act_id', 0);
  237. }
  238. $segmentNo = self::normalizeSegmentNo(getProp($task, 'alias_segment_id', ''));
  239. if ($segmentNo !== '') {
  240. $context['alias_segment_id'] = $segmentNo;
  241. }
  242. if ($context) {
  243. $anchor = self::resolve($context);
  244. }
  245. }
  246. self::$taskAnchorCache[$cacheKey] = $anchor;
  247. }
  248. return self::$taskAnchorCache[$cacheKey];
  249. }
  250. /**
  251. * 动漫ID → 绑定剧本ID(未绑定返回 0)
  252. *
  253. * @param int $animeId
  254. * @return int
  255. */
  256. public static function scriptIdByAnime(int $animeId): int
  257. {
  258. $animeRow = self::animeRowById($animeId);
  259. return (int)($animeRow['script_id'] ?? 0);
  260. }
  261. /**
  262. * 动漫ID → 动漫名(动漫不存在返回空串)
  263. *
  264. * @param int $animeId
  265. * @return string
  266. */
  267. public static function animeNameById(int $animeId): string
  268. {
  269. $animeRow = self::animeRowById($animeId);
  270. return trim((string)($animeRow['anime_name'] ?? ''));
  271. }
  272. /**
  273. * 动漫ID → 动漫行数据
  274. *
  275. * @param int $animeId
  276. * @return array
  277. */
  278. private static function animeRowById(int $animeId): array
  279. {
  280. if ($animeId <= 0) {
  281. return [];
  282. }
  283. if (!array_key_exists($animeId, self::$animeRowCache)) {
  284. $row = DB::table('mp_animes')->where('id', $animeId)->first(['id', 'anime_name', 'script_id']);
  285. self::$animeRowCache[$animeId] = $row ? (array)$row : [];
  286. }
  287. return self::$animeRowCache[$animeId];
  288. }
  289. /**
  290. * 剧本分集分组ID(mp_script_episode_group.id)→ 剧本ID
  291. *
  292. * @param int $groupId
  293. * @return int
  294. */
  295. public static function scriptIdByGroup(int $groupId): int
  296. {
  297. if ($groupId <= 0) {
  298. return 0;
  299. }
  300. if (!array_key_exists($groupId, self::$groupScriptCache)) {
  301. self::$groupScriptCache[$groupId] = (int)DB::table('mp_script_episode_group')->where('id', $groupId)->value('script_id');
  302. }
  303. return (int)self::$groupScriptCache[$groupId];
  304. }
  305. /**
  306. * 剧本ID → 剧本名(剧本不存在返回空串)
  307. *
  308. * @param int $scriptId
  309. * @return string
  310. */
  311. public static function scriptNameById(int $scriptId): string
  312. {
  313. if ($scriptId <= 0) {
  314. return '';
  315. }
  316. if (!array_key_exists($scriptId, self::$scriptNameCache)) {
  317. self::$scriptNameCache[$scriptId] = trim((string)DB::table('mp_scripts')->where('id', $scriptId)->value('script_name'));
  318. }
  319. return (string)self::$scriptNameCache[$scriptId];
  320. }
  321. /**
  322. * 动漫ID + 集数 → 分集ID
  323. *
  324. * @param int $animeId
  325. * @param int $episodeNumber
  326. * @return int
  327. */
  328. public static function episodeIdByNumber(int $animeId, int $episodeNumber): int
  329. {
  330. if ($animeId <= 0 || $episodeNumber <= 0) {
  331. return 0;
  332. }
  333. $cacheKey = $animeId . '-' . $episodeNumber;
  334. if (!array_key_exists($cacheKey, self::$episodeIdCache)) {
  335. self::$episodeIdCache[$cacheKey] = (int)DB::table('mp_anime_episodes')
  336. ->where('anime_id', $animeId)
  337. ->where('episode_number', $episodeNumber)
  338. ->value('id');
  339. }
  340. return (int)self::$episodeIdCache[$cacheKey];
  341. }
  342. /**
  343. * 分集ID → 分集行数据(mp_anime_episodes)
  344. *
  345. * @param int $episodeId
  346. * @return array
  347. */
  348. public static function fromEpisodeId(int $episodeId): array
  349. {
  350. if ($episodeId <= 0) {
  351. return [];
  352. }
  353. if (!array_key_exists($episodeId, self::$episodeRowCache)) {
  354. $row = DB::table('mp_anime_episodes')->where('id', $episodeId)->first(['id', 'anime_id', 'episode_number']);
  355. self::$episodeRowCache[$episodeId] = $row ? (array)$row : [];
  356. }
  357. return self::$episodeRowCache[$episodeId];
  358. }
  359. /**
  360. * 归一化分镜号:空、0、字符串 "0" 均视为未传
  361. *
  362. * @param mixed $segmentNo
  363. * @return string
  364. */
  365. private static function normalizeSegmentNo($segmentNo): string
  366. {
  367. $segmentNo = trim((string)$segmentNo);
  368. if ($segmentNo === '' || $segmentNo === '0') {
  369. return '';
  370. }
  371. return $segmentNo;
  372. }
  373. }