|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|