BackfillPointsScriptCommand.php 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\PointsService;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Schema;
  7. class BackfillPointsScriptCommand extends Command
  8. {
  9. /**
  10. * The name and signature of the console command.
  11. *
  12. * @var string
  13. */
  14. protected $signature = 'points:backfill-script
  15. {--type= : 只回填指定类型(video/image/chat),默认三种类型全部回填}
  16. {--dry-run : 只统计将回填的记录数,不实际更新}
  17. {--force : 重算已有锚点的记录(默认只处理缺失动漫/剧本的记录)}
  18. {--skip-charge-info : 只回填明细列,不改写 charge_info}
  19. {--charge-info-only : 只补齐 charge_info 缺失的 anime_id/script_id(依据明细列已回填的值)}
  20. {--limit= : 最多处理记录数(默认全部)}';
  21. /**
  22. * The console command description.
  23. *
  24. * @var string
  25. */
  26. protected $description = '回填积分明细的动漫ID/动漫名、剧本ID/剧本名,并同步补写 charge_info 的 anime_id/script_id';
  27. /**
  28. * 每批处理条数
  29. */
  30. const CHUNK_SIZE = 500;
  31. /**
  32. * Execute the console command.
  33. *
  34. * @param PointsService $pointsService
  35. * @return int
  36. */
  37. public function handle(PointsService $pointsService)
  38. {
  39. if (!Schema::hasColumn('mp_user_points_details', 'script_id')) {
  40. $this->error('mp_user_points_details 缺少 script_id/script_name 字段,请先执行 php artisan migrate');
  41. return 1;
  42. }
  43. $dryRun = (bool)$this->option('dry-run');
  44. $force = (bool)$this->option('force');
  45. $skipChargeInfo = (bool)$this->option('skip-charge-info');
  46. $chargeInfoOnly = (bool)$this->option('charge-info-only');
  47. $limit = (int)$this->option('limit');
  48. if ($limit < 0) {
  49. $limit = 0;
  50. }
  51. if ($chargeInfoOnly) {
  52. return $this->fillChargeInfoOnly($dryRun);
  53. }
  54. $type = trim((string)$this->option('type'));
  55. $types = in_array($type, ['video', 'image', 'chat'], true) ? [$type] : ['video', 'image', 'chat'];
  56. $query = DB::table('mp_user_points_details')
  57. ->select('id', 'type', 'task_id', 'charge_info')
  58. ->whereIn('type', $types)
  59. ->orderBy('id');
  60. if (!$force) {
  61. $query->where(function ($q) {
  62. $q->whereNull('anime_id')
  63. ->orWhereNull('anime_name')
  64. ->orWhereNull('script_id')
  65. ->orWhere('script_id', 0);
  66. });
  67. }
  68. $total = (clone $query)->count();
  69. $this->info('待回填明细总数: ' . $total . ($dryRun ? '(dry-run 模式,不更新)' : ''));
  70. if ($total <= 0) {
  71. return 0;
  72. }
  73. if ($limit > 0) {
  74. $this->info('本次最多处理: ' . $limit . ' 条');
  75. }
  76. $scanned = 0;
  77. $filled = 0;
  78. $skipped = 0;
  79. $chargeInfoUpdated = 0;
  80. $query->chunkById(self::CHUNK_SIZE, function ($rows) use ($pointsService, $dryRun, $skipChargeInfo, $limit, &$scanned, &$filled, &$skipped, &$chargeInfoUpdated) {
  81. /** @var array 锚点 => 待更新明细ID */
  82. $pending = [];
  83. foreach ($rows as $row) {
  84. if ($limit > 0 && $scanned >= $limit) {
  85. // 达到上限:先跳出明细循环,保证已解析的数据在下方统一落库
  86. break;
  87. }
  88. $scanned++;
  89. $chargeInfo = $row->charge_info;
  90. if (is_string($chargeInfo)) {
  91. $chargeInfo = json_decode($chargeInfo, true);
  92. }
  93. $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
  94. $context = $pointsService->resolveScriptContext(
  95. $chargeInfo,
  96. $row->task_id === null ? null : (int)$row->task_id,
  97. (string)$row->type
  98. );
  99. if (empty($context['anime_id']) && empty($context['script_id'])) {
  100. $skipped++;
  101. continue;
  102. }
  103. // charge_info 中缺失的锚点才补(已有值优先,不改写历史原始值)
  104. $needChargeInfoAnime = !$skipChargeInfo && !empty($context['anime_id']) && empty(getProp($chargeInfo, 'anime_id', ''));
  105. $needChargeInfoScript = !$skipChargeInfo && !empty($context['script_id']) && empty(getProp($chargeInfo, 'script_id', ''));
  106. $mask = ($needChargeInfoAnime ? 1 : 0) + ($needChargeInfoScript ? 2 : 0);
  107. $key = (int)$context['anime_id'] . '|' . (string)$context['anime_name'] . '|' . (int)$context['script_id'] . '|' . (string)$context['script_name'] . '|' . $mask;
  108. $pending[$key][] = (int)$row->id;
  109. }
  110. foreach ($pending as $key => $ids) {
  111. list($animeId, $animeName, $scriptId, $scriptName, $mask) = explode('|', $key, 5);
  112. $animeId = (int)$animeId;
  113. $scriptId = (int)$scriptId;
  114. $mask = (int)$mask;
  115. $filled += count($ids);
  116. if ($dryRun) {
  117. continue;
  118. }
  119. DB::table('mp_user_points_details')->whereIn('id', $ids)->update([
  120. 'anime_id' => $animeId ?: null,
  121. 'anime_name' => $animeName !== '' ? $animeName : null,
  122. 'script_id' => $scriptId ?: null,
  123. 'script_name' => $scriptName !== '' ? $scriptName : null,
  124. ]);
  125. // charge_info 补写 anime_id / script_id(JSON 原子写入,不影响其他键)
  126. if ($mask > 0 && ($animeId || $scriptId)) {
  127. $this->updateChargeInfoAnchors($ids, $animeId, $scriptId, $mask);
  128. $chargeInfoUpdated += count($ids);
  129. }
  130. }
  131. // 达到处理上限时结束分片遍历
  132. return !($limit > 0 && $scanned >= $limit);
  133. });
  134. $this->info('扫描明细: ' . $scanned . ' 条,可回填: ' . $filled . ' 条,无动漫/剧本线索: ' . $skipped . ' 条');
  135. if (!$skipChargeInfo) {
  136. $this->info('charge_info 补写: ' . $chargeInfoUpdated . ' 条' . ($dryRun ? '(dry-run 未写入)' : ''));
  137. }
  138. $this->info($dryRun ? 'dry-run 结束,未更新任何数据' : '回填完成');
  139. return 0;
  140. }
  141. /**
  142. * 只补齐 charge_info 缺失的锚点(依据明细列已有值,不做锚点解析)
  143. *
  144. * 用于修正“列已回填但 charge_info 未补写”的行,速度快、可重复执行。
  145. *
  146. * @param bool $dryRun
  147. * @return int
  148. */
  149. private function fillChargeInfoOnly(bool $dryRun): int
  150. {
  151. $targets = [
  152. 'anime_id' => "anime_id is not null and JSON_CONTAINS_PATH(charge_info, 'one', '$.anime_id') = 0",
  153. 'script_id' => "script_id is not null and JSON_CONTAINS_PATH(charge_info, 'one', '$.script_id') = 0",
  154. ];
  155. foreach ($targets as $column => $condition) {
  156. $count = DB::table('mp_user_points_details')->whereRaw($condition)->count();
  157. if ($dryRun) {
  158. $this->info('charge_info 待补 ' . $column . ': ' . $count . ' 条(dry-run 未写入)');
  159. continue;
  160. }
  161. if ($count <= 0) {
  162. $this->info('charge_info 待补 ' . $column . ': 0 条');
  163. continue;
  164. }
  165. $affected = DB::update(
  166. 'UPDATE mp_user_points_details SET charge_info = JSON_SET(charge_info, \'$.' . $column . '\', ' . $column . ')'
  167. . ' WHERE ' . $condition
  168. );
  169. $this->info('charge_info 补写 ' . $column . ': ' . $affected . ' 条');
  170. }
  171. return 0;
  172. }
  173. /**
  174. * 补写 charge_info 的 anime_id / script_id(只补缺失的键,已有值保留)
  175. *
  176. * @param array $ids 明细ID
  177. * @param int $animeId
  178. * @param int $scriptId
  179. * @param int $mask 1=补 anime_id,2=补 script_id,3=两者都补
  180. * @return void
  181. */
  182. private function updateChargeInfoAnchors(array $ids, int $animeId, int $scriptId, int $mask): void
  183. {
  184. $paths = [];
  185. $bindings = [];
  186. if (($mask & 1) === 1 && $animeId > 0) {
  187. $paths[] = "'$.anime_id', ?";
  188. $bindings[] = $animeId;
  189. }
  190. if (($mask & 2) === 2 && $scriptId > 0) {
  191. $paths[] = "'$.script_id', ?";
  192. $bindings[] = $scriptId;
  193. }
  194. if (!$paths) {
  195. return;
  196. }
  197. // 用 JSON_SET 原子补写锚点:只影响指定键,charge_info 其他内容保持不变
  198. $sql = 'UPDATE mp_user_points_details'
  199. . ' SET charge_info = JSON_SET(COALESCE(charge_info, JSON_OBJECT()), ' . implode(', ', $paths) . ')'
  200. . ' WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
  201. DB::update($sql, array_merge($bindings, array_map('intval', $ids)));
  202. }
  203. }