diff --git a/app/admin/controller/mall/DailyPush.php b/app/admin/controller/mall/DailyPush.php index 1e570b2..68dd280 100644 --- a/app/admin/controller/mall/DailyPush.php +++ b/app/admin/controller/mall/DailyPush.php @@ -3,6 +3,7 @@ namespace app\admin\controller\mall; use Throwable; +use app\common\library\MallDailyPushBackfill; use app\common\controller\Backend; use support\Response; use Webman\Http\Request; @@ -57,5 +58,55 @@ class DailyPush extends Backend return $this->_index(); } + + /** + * 按历史 mall_daily_push 记录回补用户信息 + */ + public function backfillUsers(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $dateFrom = trim(strval($request->post('date_from', $request->get('date_from', '')))); + $dateTo = trim(strval($request->post('date_to', $request->get('date_to', '')))); + $limitRaw = strval($request->post('limit', $request->get('limit', '0'))); + $dryRunRaw = strval($request->post('dry_run', $request->get('dry_run', '0'))); + + if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) { + return $this->error('date_from 格式错误,需为 YYYY-MM-DD'); + } + if ($dateTo !== '' && !$this->isValidDate($dateTo)) { + return $this->error('date_to 格式错误,需为 YYYY-MM-DD'); + } + if (!is_numeric($limitRaw)) { + return $this->error('limit 必须是数字'); + } + $limit = intval($limitRaw); + if ($limit < 0) { + return $this->error('limit 不能小于 0'); + } + $dryRun = in_array(strtolower($dryRunRaw), ['1', 'true', 'yes', 'on'], true); + + $service = new MallDailyPushBackfill(); + $result = $service->backfill( + $dateFrom === '' ? null : $dateFrom, + $dateTo === '' ? null : $dateTo, + $limit, + $dryRun + ); + + return $this->success('', $result); + } + + private function isValidDate(string $value): bool + { + $dt = \DateTime::createFromFormat('Y-m-d', $value); + if (!$dt) { + return false; + } + return $dt->format('Y-m-d') === $value; + } } diff --git a/app/command/MallDailyPushBackfillUsers.php b/app/command/MallDailyPushBackfillUsers.php new file mode 100644 index 0000000..506f621 --- /dev/null +++ b/app/command/MallDailyPushBackfillUsers.php @@ -0,0 +1,91 @@ +addOption('from', null, InputOption::VALUE_OPTIONAL, '开始日期(含),格式 YYYY-MM-DD'); + $this->addOption('to', null, InputOption::VALUE_OPTIONAL, '结束日期(含),格式 YYYY-MM-DD'); + $this->addOption('limit', null, InputOption::VALUE_OPTIONAL, '最多处理用户数(按 user_id 去重后)', '0'); + $this->addOption('dry-run', null, InputOption::VALUE_NONE, '仅预览,不落库'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $dateFrom = strval($input->getOption('from') ?? ''); + $dateTo = strval($input->getOption('to') ?? ''); + $limitRaw = strval($input->getOption('limit') ?? '0'); + $dryRun = boolval($input->getOption('dry-run')); + + if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) { + $output->writeln('参数 --from 日期格式错误,需为 YYYY-MM-DD'); + return self::FAILURE; + } + if ($dateTo !== '' && !$this->isValidDate($dateTo)) { + $output->writeln('参数 --to 日期格式错误,需为 YYYY-MM-DD'); + return self::FAILURE; + } + if (!is_numeric($limitRaw)) { + $output->writeln('参数 --limit 必须是数字'); + return self::FAILURE; + } + + $limit = intval($limitRaw); + if ($limit < 0) { + $output->writeln('参数 --limit 不能小于 0'); + return self::FAILURE; + } + + $service = new MallDailyPushBackfill(); + $result = $service->backfill( + $dateFrom === '' ? null : $dateFrom, + $dateTo === '' ? null : $dateTo, + $limit, + $dryRun + ); + + $output->writeln('DailyPush 用户回补完成'); + $output->writeln('dry_run: ' . ($result['dry_run'] ? 'true' : 'false')); + $output->writeln('scanned_rows: ' . strval($result['scanned_rows'])); + $output->writeln('target_users: ' . strval($result['target_users'])); + $output->writeln('processed_users: ' . strval($result['processed_users'])); + $output->writeln('created_users: ' . strval($result['created_users'])); + $output->writeln('updated_users: ' . strval($result['updated_users'])); + $output->writeln('unchanged_users: ' . strval($result['unchanged_users'])); + $output->writeln('failed_users: ' . strval($result['failed_users'])); + + $errors = $result['errors']; + if (is_array($errors) && !empty($errors)) { + $output->writeln('错误明细:'); + foreach ($errors as $err) { + $uid = strval($err['user_id'] ?? ''); + $msg = strval($err['message'] ?? ''); + $output->writeln("- user_id={$uid}, message={$msg}"); + } + } + + return self::SUCCESS; + } + + private function isValidDate(string $value): bool + { + $dt = \DateTime::createFromFormat('Y-m-d', $value); + if (!$dt) { + return false; + } + return $dt->format('Y-m-d') === $value; + } +} + diff --git a/app/common/library/MallDailyPushBackfill.php b/app/common/library/MallDailyPushBackfill.php new file mode 100644 index 0000000..4bc9c55 --- /dev/null +++ b/app/common/library/MallDailyPushBackfill.php @@ -0,0 +1,160 @@ + + */ + public function backfill(?string $dateFrom, ?string $dateTo, int $limit = 0, bool $dryRun = false): array + { + $query = MallDailyPush::order('create_time', 'desc')->order('id', 'desc'); + if ($dateFrom !== null && $dateFrom !== '') { + $query->where('date', '>=', $dateFrom); + } + if ($dateTo !== null && $dateTo !== '') { + $query->where('date', '<=', $dateTo); + } + + $rows = $query->select(); + $seenUserIds = []; + + $stats = [ + 'dry_run' => $dryRun, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'limit' => $limit, + 'scanned_rows' => 0, + 'target_users' => 0, + 'processed_users' => 0, + 'created_users' => 0, + 'updated_users' => 0, + 'unchanged_users' => 0, + 'failed_users' => 0, + 'errors' => [], + ]; + + foreach ($rows as $row) { + $stats['scanned_rows']++; + $playxUserId = trim(strval($row->user_id ?? '')); + if ($playxUserId === '' || isset($seenUserIds[$playxUserId])) { + continue; + } + $seenUserIds[$playxUserId] = true; + $stats['target_users']++; + + if ($limit > 0 && $stats['target_users'] > $limit) { + break; + } + + $username = trim(strval($row->username ?? '')); + try { + $result = $this->ensureAssetForPlayx($playxUserId, $username, $dryRun); + $stats['processed_users']++; + if ($result['created']) { + $stats['created_users']++; + } elseif ($result['updated']) { + $stats['updated_users']++; + } else { + $stats['unchanged_users']++; + } + } catch (Throwable $e) { + $stats['failed_users']++; + $stats['errors'][] = [ + 'user_id' => $playxUserId, + 'message' => $e->getMessage(), + ]; + } + } + + return $stats; + } + + /** + * @return array{created:bool,updated:bool} + */ + private function ensureAssetForPlayx(string $playxUserId, string $username, bool $dryRun): array + { + $asset = MallUserAsset::where('playx_user_id', $playxUserId)->find(); + if ($asset) { + $updated = false; + $uname = trim($username); + if ($uname !== '' && strval($asset->username ?? '') !== $uname) { + $asset->username = $uname; + $updated = true; + } + if ($updated && !$dryRun) { + $asset->save(); + } + return ['created' => false, 'updated' => $updated]; + } + + $effectiveUsername = trim($username); + if ($effectiveUsername === '') { + $effectiveUsername = 'playx_' . $playxUserId; + } + + $byName = MallUserAsset::where('username', $effectiveUsername)->find(); + if ($byName) { + $updated = strval($byName->playx_user_id ?? '') !== $playxUserId; + if ($updated && !$dryRun) { + $byName->playx_user_id = $playxUserId; + $byName->save(); + } + return ['created' => false, 'updated' => $updated]; + } + + if ($dryRun) { + return ['created' => true, 'updated' => false]; + } + + $phone = $this->buildTempPhone(); + if ($phone === null) { + throw new \RuntimeException('Failed to allocate phone for playx user'); + } + $pwd = hash_password(Random::build('alnum', 16)); + $now = time(); + $created = MallUserAsset::create([ + 'playx_user_id' => $playxUserId, + 'username' => $effectiveUsername, + 'phone' => $phone, + 'password' => $pwd, + 'admin_id' => 0, + 'locked_points' => 0, + 'available_points' => 0, + 'today_limit' => 0, + 'today_claimed' => 0, + 'today_limit_date' => null, + 'create_time' => $now, + 'update_time' => $now, + ]); + + if (!$created) { + throw new \RuntimeException('Failed to create mall_user_asset'); + } + return ['created' => true, 'updated' => false]; + } + + private function buildTempPhone(): ?string + { + for ($i = 0; $i < 8; $i++) { + $candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT); + if (!MallUserAsset::where('phone', $candidate)->find()) { + return $candidate; + } + } + return null; + } +} + diff --git a/config/route.php b/config/route.php index c6aa8a7..613e54c 100644 --- a/config/route.php +++ b/config/route.php @@ -214,6 +214,9 @@ Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::cl // admin/user/moneyLog Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']); +// admin/mall/dailyPush +Route::post('/admin/mall/dailyPush/backfillUsers', [\app\admin\controller\mall\DailyPush::class, 'backfillUsers']); + // admin/routine/config Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']); Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']);