Compare commits
2 Commits
7af40bdd1f
...
0a1109c109
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a1109c109 | |||
| eb3bdaa005 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -469,11 +469,91 @@ class Playx extends Api
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 联调:合作方 JWT payload 的 base64url 解码(不验签),仅用于 dev_verify_token_exact 命中后的 session 字段。
|
||||
*
|
||||
* @return array{sub?: string, user_fullname?: string, exp?: int}|null
|
||||
*/
|
||||
private function parsePartnerJwtPayloadForDev(string $jwt): ?array
|
||||
{
|
||||
$parts = explode('.', $jwt);
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
$payload = $parts[1];
|
||||
$b64 = strtr($payload, '-_', '+/');
|
||||
$pad = strlen($b64) % 4;
|
||||
if ($pad > 0) {
|
||||
$b64 .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
$raw = base64_decode($b64, true);
|
||||
if ($raw === false || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地校验 temLogin 等写入的商城 token(类型 muser),写入 mall_session
|
||||
*/
|
||||
private function verifyTokenLocal(string $token): Response
|
||||
{
|
||||
$devExact = strval(config('playx.dev_verify_token_exact', ''));
|
||||
if ($devExact !== '' && hash_equals($devExact, $token)) {
|
||||
$payload = $this->parsePartnerJwtPayloadForDev($token);
|
||||
if ($payload === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
if (isset($payload['exp'])) {
|
||||
$exp = intval($payload['exp']);
|
||||
if ($exp > 0 && $exp <= time()) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
}
|
||||
|
||||
$overrideUserId = strval(config('playx.dev_verify_session_user_id', ''));
|
||||
$playxUserId = $overrideUserId;
|
||||
if ($playxUserId === '' && isset($payload['sub']) && is_string($payload['sub'])) {
|
||||
$playxUserId = $payload['sub'];
|
||||
}
|
||||
if ($playxUserId === '') {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$overrideUsername = strval(config('playx.dev_verify_session_username', ''));
|
||||
$username = $overrideUsername;
|
||||
if ($username === '') {
|
||||
if (isset($payload['user_fullname']) && is_string($payload['user_fullname']) && $payload['user_fullname'] !== '') {
|
||||
$username = $payload['user_fullname'];
|
||||
} elseif (isset($payload['sub']) && is_string($payload['sub'])) {
|
||||
$username = $payload['sub'];
|
||||
}
|
||||
}
|
||||
|
||||
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
|
||||
$sessionId = bin2hex(random_bytes(16));
|
||||
MallSession::create([
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $playxUserId,
|
||||
'username' => $username,
|
||||
'expire_time' => $expireAt,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->success('', [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $playxUserId,
|
||||
'username' => $username,
|
||||
'token_expire_at' => date('c', $expireAt),
|
||||
]);
|
||||
}
|
||||
|
||||
$tokenData = Token::get($token);
|
||||
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
|
||||
91
app/command/MallDailyPushBackfillUsers.php
Normal file
91
app/command/MallDailyPushBackfillUsers.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\library\MallDailyPushBackfill;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('mall:daily-push:backfill-users', '根据历史每日推送回补用户信息')]
|
||||
class MallDailyPushBackfillUsers extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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('<error>参数 --from 日期格式错误,需为 YYYY-MM-DD</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($dateTo !== '' && !$this->isValidDate($dateTo)) {
|
||||
$output->writeln('<error>参数 --to 日期格式错误,需为 YYYY-MM-DD</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_numeric($limitRaw)) {
|
||||
$output->writeln('<error>参数 --limit 必须是数字</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$limit = intval($limitRaw);
|
||||
if ($limit < 0) {
|
||||
$output->writeln('<error>参数 --limit 不能小于 0</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$service = new MallDailyPushBackfill();
|
||||
$result = $service->backfill(
|
||||
$dateFrom === '' ? null : $dateFrom,
|
||||
$dateTo === '' ? null : $dateTo,
|
||||
$limit,
|
||||
$dryRun
|
||||
);
|
||||
|
||||
$output->writeln('<info>DailyPush 用户回补完成</info>');
|
||||
$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('<comment>错误明细:</comment>');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
160
app/common/library/MallDailyPushBackfill.php
Normal file
160
app/common/library/MallDailyPushBackfill.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use app\common\model\MallDailyPush;
|
||||
use app\common\model\MallUserAsset;
|
||||
use ba\Random;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 基于历史每日推送记录回补/同步用户资产主信息。
|
||||
*/
|
||||
class MallDailyPushBackfill
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,19 @@ return [
|
||||
* 联调/无 PlayX 环境可开;上线对接 PlayX 后请设为 false 并配置 api.base_url。
|
||||
*/
|
||||
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '1'), FILTER_VALIDATE_BOOLEAN),
|
||||
/**
|
||||
* 联调占位(待对方提供 token 校验接口后删除或清空):verify_token_local_only 为 true 时,
|
||||
* 若请求中的 token 与此字符串完全一致则视为有效合作方 JWT,并写入 mall_session。
|
||||
* 生产环境务必置空或通过环境变量覆盖为空。
|
||||
*/
|
||||
'dev_verify_token_exact' => strval(env(
|
||||
'PLAYX_DEV_VERIFY_TOKEN_EXACT',
|
||||
'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0bXlyIiwiYXV0aCI6IlJPTEVfTUVNQkVSIiwibWVyY2hhbnQiOiJwbHgiLCJ1c2VyX2Z1bGxuYW1lIjoiQW5uYSIsImN1cnJlbmN5IjoiTVlSIiwibGFuZ3VhZ2UiOiJ6aC1DTiIsInZpcCI6ZmFsc2UsImV4cCI6MTc3NjkzODI4N30.XVvNcnBcAEqdxqoNRmygkl826bLUfwH2xyBE8wiSTOSeJjv99DHKJOAgsE4ukJ-M_t1hPbraz9GO4qvOszOeDg'
|
||||
)),
|
||||
/** 命中 dev_verify_token_exact 时写入 session 的展示用户名(空则取 JWT user_fullname 或 sub) */
|
||||
'dev_verify_session_username' => strval(env('PLAYX_DEV_VERIFY_SESSION_USERNAME', 'yangyang123')),
|
||||
/** 命中 dev_verify_token_exact 时写入 session 的 user_id(空则取 JWT sub) */
|
||||
'dev_verify_session_user_id' => strval(env('PLAYX_DEV_VERIFY_SESSION_USER_ID', '')),
|
||||
// PlayX API 配置(商城调用 PlayX 时使用)
|
||||
'api' => [
|
||||
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user