ScriptAnchor.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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. * 分镜行ID(mp_episode_segments.id)→ 分镜行数据
  162. *
  163. * @param int $rowId
  164. * @return array
  165. */
  166. public static function fromSegmentRowId(int $rowId): array
  167. {
  168. if ($rowId <= 0) {
  169. return [];
  170. }
  171. if (!array_key_exists($rowId, self::$segmentRowCache)) {
  172. $row = DB::table('mp_episode_segments')->where('id', $rowId)->first(self::SEGMENT_COLUMNS);
  173. self::$segmentRowCache[$rowId] = $row ? (array)$row : [];
  174. }
  175. return self::$segmentRowCache[$rowId];
  176. }
  177. /**
  178. * 分镜号(mp_episode_segments.segment_id)→ 分镜行数据
  179. *
  180. * @param string $segmentNo
  181. * @return array
  182. */
  183. public static function fromSegmentNo(string $segmentNo): array
  184. {
  185. $segmentNo = self::normalizeSegmentNo($segmentNo);
  186. if ($segmentNo === '') {
  187. return [];
  188. }
  189. if (!array_key_exists($segmentNo, self::$segmentNoCache)) {
  190. $row = DB::table('mp_episode_segments')->where('segment_id', $segmentNo)->first(self::SEGMENT_COLUMNS);
  191. self::$segmentNoCache[$segmentNo] = $row ? (array)$row : [];
  192. }
  193. return self::$segmentNoCache[$segmentNo];
  194. }
  195. /**
  196. * 视频/图片任务ID → 锚点(回查任务表保存的关联片段,兼容未写锚点的历史任务)
  197. *
  198. * @param string $type 明细类型(video/image)
  199. * @param int $taskId 任务表主键
  200. * @return array
  201. */
  202. public static function anchorByTask(string $type, int $taskId): array
  203. {
  204. if ($taskId <= 0 || !in_array($type, [MpUserPointsDetail::TYPE_VIDEO, MpUserPointsDetail::TYPE_IMAGE], true)) {
  205. return self::emptyAnchor();
  206. }
  207. $cacheKey = $type . ':' . $taskId;
  208. if (!array_key_exists($cacheKey, self::$taskAnchorCache)) {
  209. $table = $type === MpUserPointsDetail::TYPE_VIDEO ? 'mp_generate_video_tasks' : 'mp_generate_pic_tasks';
  210. $task = DB::table($table)->where('id', $taskId)->first(['alias_act_id', 'alias_segment_id']);
  211. $anchor = self::emptyAnchor();
  212. if ($task) {
  213. $context = [];
  214. if ((int)getProp($task, 'alias_act_id', 0) > 0) {
  215. $context['alias_act_id'] = (int)getProp($task, 'alias_act_id', 0);
  216. }
  217. $segmentNo = self::normalizeSegmentNo(getProp($task, 'alias_segment_id', ''));
  218. if ($segmentNo !== '') {
  219. $context['alias_segment_id'] = $segmentNo;
  220. }
  221. if ($context) {
  222. $anchor = self::resolve($context);
  223. }
  224. }
  225. self::$taskAnchorCache[$cacheKey] = $anchor;
  226. }
  227. return self::$taskAnchorCache[$cacheKey];
  228. }
  229. /**
  230. * 动漫ID → 绑定剧本ID(未绑定返回 0)
  231. *
  232. * @param int $animeId
  233. * @return int
  234. */
  235. public static function scriptIdByAnime(int $animeId): int
  236. {
  237. $animeRow = self::animeRowById($animeId);
  238. return (int)($animeRow['script_id'] ?? 0);
  239. }
  240. /**
  241. * 动漫ID → 动漫名(动漫不存在返回空串)
  242. *
  243. * @param int $animeId
  244. * @return string
  245. */
  246. public static function animeNameById(int $animeId): string
  247. {
  248. $animeRow = self::animeRowById($animeId);
  249. return trim((string)($animeRow['anime_name'] ?? ''));
  250. }
  251. /**
  252. * 动漫ID → 动漫行数据
  253. *
  254. * @param int $animeId
  255. * @return array
  256. */
  257. private static function animeRowById(int $animeId): array
  258. {
  259. if ($animeId <= 0) {
  260. return [];
  261. }
  262. if (!array_key_exists($animeId, self::$animeRowCache)) {
  263. $row = DB::table('mp_animes')->where('id', $animeId)->first(['id', 'anime_name', 'script_id']);
  264. self::$animeRowCache[$animeId] = $row ? (array)$row : [];
  265. }
  266. return self::$animeRowCache[$animeId];
  267. }
  268. /**
  269. * 剧本分集分组ID(mp_script_episode_group.id)→ 剧本ID
  270. *
  271. * @param int $groupId
  272. * @return int
  273. */
  274. public static function scriptIdByGroup(int $groupId): int
  275. {
  276. if ($groupId <= 0) {
  277. return 0;
  278. }
  279. if (!array_key_exists($groupId, self::$groupScriptCache)) {
  280. self::$groupScriptCache[$groupId] = (int)DB::table('mp_script_episode_group')->where('id', $groupId)->value('script_id');
  281. }
  282. return (int)self::$groupScriptCache[$groupId];
  283. }
  284. /**
  285. * 剧本ID → 剧本名(剧本不存在返回空串)
  286. *
  287. * @param int $scriptId
  288. * @return string
  289. */
  290. public static function scriptNameById(int $scriptId): string
  291. {
  292. if ($scriptId <= 0) {
  293. return '';
  294. }
  295. if (!array_key_exists($scriptId, self::$scriptNameCache)) {
  296. self::$scriptNameCache[$scriptId] = trim((string)DB::table('mp_scripts')->where('id', $scriptId)->value('script_name'));
  297. }
  298. return (string)self::$scriptNameCache[$scriptId];
  299. }
  300. /**
  301. * 动漫ID + 集数 → 分集ID
  302. *
  303. * @param int $animeId
  304. * @param int $episodeNumber
  305. * @return int
  306. */
  307. public static function episodeIdByNumber(int $animeId, int $episodeNumber): int
  308. {
  309. if ($animeId <= 0 || $episodeNumber <= 0) {
  310. return 0;
  311. }
  312. $cacheKey = $animeId . '-' . $episodeNumber;
  313. if (!array_key_exists($cacheKey, self::$episodeIdCache)) {
  314. self::$episodeIdCache[$cacheKey] = (int)DB::table('mp_anime_episodes')
  315. ->where('anime_id', $animeId)
  316. ->where('episode_number', $episodeNumber)
  317. ->value('id');
  318. }
  319. return (int)self::$episodeIdCache[$cacheKey];
  320. }
  321. /**
  322. * 分集ID → 分集行数据(mp_anime_episodes)
  323. *
  324. * @param int $episodeId
  325. * @return array
  326. */
  327. public static function fromEpisodeId(int $episodeId): array
  328. {
  329. if ($episodeId <= 0) {
  330. return [];
  331. }
  332. if (!array_key_exists($episodeId, self::$episodeRowCache)) {
  333. $row = DB::table('mp_anime_episodes')->where('id', $episodeId)->first(['id', 'anime_id', 'episode_number']);
  334. self::$episodeRowCache[$episodeId] = $row ? (array)$row : [];
  335. }
  336. return self::$episodeRowCache[$episodeId];
  337. }
  338. /**
  339. * 归一化分镜号:空、0、字符串 "0" 均视为未传
  340. *
  341. * @param mixed $segmentNo
  342. * @return string
  343. */
  344. private static function normalizeSegmentNo($segmentNo): string
  345. {
  346. $segmentNo = trim((string)$segmentNo);
  347. if ($segmentNo === '' || $segmentNo === '0') {
  348. return '';
  349. }
  350. return $segmentNo;
  351. }
  352. }