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']);