230 lines
8.2 KiB
PHP
230 lines
8.2 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\logic;
|
||
|
||
use app\dice\model\player\DicePlayer;
|
||
use app\api\cache\UserCache;
|
||
use plugin\saiadmin\app\model\system\SystemDept;
|
||
use plugin\saiadmin\app\model\system\SystemUser;
|
||
use plugin\saiadmin\exception\ApiException;
|
||
use Tinywan\Jwt\JwtToken;
|
||
|
||
/**
|
||
* API 用户登录/注册逻辑(基于 DicePlayer 表)
|
||
* 手机号格式限制:+60(马来西亚)
|
||
*/
|
||
class UserLogic
|
||
{
|
||
/** 手机号正则:+60 开头,后跟 9–10 位数字(马来西亚) */
|
||
private const PHONE_REGEX = '/^\+60\d{9,10}$/';
|
||
|
||
/** 与 DicePlayerLogic 保持一致的密码盐,用于登录校验与注册写入 */
|
||
private const PASSWORD_SALT = 'dice_player_salt_2024';
|
||
|
||
/** 状态:正常 */
|
||
private const STATUS_NORMAL = 1;
|
||
|
||
/**
|
||
* 手机号格式校验:+60 开头
|
||
*/
|
||
public static function validatePhone(string $phone): void
|
||
{
|
||
if (!preg_match(self::PHONE_REGEX, $phone)) {
|
||
throw new ApiException('Invalid phone format, only +60 Malaysia numbers supported (e.g. +60123456789)');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 与 DicePlayerLogic 一致的密码加密:md5(salt . password)
|
||
*/
|
||
private function hashPassword(string $password): string
|
||
{
|
||
return md5(self::PASSWORD_SALT . $password);
|
||
}
|
||
|
||
/**
|
||
* 根据 parent_id 向上遍历找到顶级部门(parent_id=0)
|
||
*/
|
||
private static function getTopDeptIdByParentId(int $deptId): ?int
|
||
{
|
||
$currentId = $deptId;
|
||
$visited = [];
|
||
while ($currentId > 0 && !isset($visited[$currentId])) {
|
||
$visited[$currentId] = true;
|
||
$dept = SystemDept::find($currentId);
|
||
if (!$dept) {
|
||
return null;
|
||
}
|
||
$parentId = (int) ($dept->parent_id ?? 0);
|
||
if ($parentId === 0) {
|
||
return $currentId;
|
||
}
|
||
$currentId = $parentId;
|
||
}
|
||
return $currentId > 0 ? $currentId : null;
|
||
}
|
||
|
||
/**
|
||
* 根据顶级部门 id,递归获取其下所有部门 id(含自身),仅用 id 和 parent_id
|
||
*/
|
||
private static function getAllDeptIdsUnderTop(int $topId): array
|
||
{
|
||
$deptIds = [$topId];
|
||
$prevCount = 0;
|
||
while (count($deptIds) > $prevCount) {
|
||
$prevCount = count($deptIds);
|
||
$children = SystemDept::whereIn('parent_id', $deptIds)->column('id');
|
||
$deptIds = array_unique(array_merge($deptIds, array_map('intval', $children)));
|
||
}
|
||
return array_values($deptIds);
|
||
}
|
||
|
||
/**
|
||
* 根据 agent_id 获取当前管理员所在顶级部门下的所有管理员 ID 列表
|
||
* 使用 SystemDept 的 id 和 parent_id 字段遍历:先向上找顶级部门(parent_id=0),再向下收集所有子部门
|
||
* 用于 getGameUrl 接口判断 DicePlayer 是否属于该部门,同顶级部门下不重复创建玩家
|
||
*
|
||
* @param string $agentId 代理标识(sa_system_user.agent_id)
|
||
* @return int[] 管理员 ID 列表,空数组表示未找到或无法解析
|
||
*/
|
||
public static function getAdminIdsByAgentIdTopDept(string $agentId): array
|
||
{
|
||
$agentId = trim($agentId);
|
||
if ($agentId === '') {
|
||
return [];
|
||
}
|
||
$admin = SystemUser::where('agent_id', $agentId)->find();
|
||
if (!$admin) {
|
||
return [];
|
||
}
|
||
$deptId = $admin->dept_id ?? null;
|
||
if ($deptId === null || $deptId === '') {
|
||
return [(int) $admin->id];
|
||
}
|
||
$deptId = (int) $deptId;
|
||
$topId = self::getTopDeptIdByParentId($deptId);
|
||
if ($topId === null) {
|
||
return [(int) $admin->id];
|
||
}
|
||
$deptIds = self::getAllDeptIdsUnderTop($topId);
|
||
if (empty($deptIds)) {
|
||
$deptIds = [$deptId];
|
||
}
|
||
$adminIds = SystemUser::whereIn('dept_id', $deptIds)->column('id');
|
||
return array_map('intval', $adminIds);
|
||
}
|
||
|
||
/**
|
||
* 登录(JSON:username, password, lang, coin, time)
|
||
* 存在则校验密码并更新 coin(累加);不存在则创建用户并写入 coin。
|
||
* 将会话写入 Redis,返回 token 与前端连接地址。
|
||
*
|
||
* @param int|null $adminId 创建新用户时关联的后台管理员ID(sa_system_user.id),可选
|
||
* @param int[]|null $adminIdsInTopDept 当前管理员顶级部门下的所有管理员ID,用于按部门范围查找玩家;为空时退化为仅按 username 查找
|
||
*/
|
||
public function loginByUsername(string $username, string $password, string $lang, float $coin, string $time, ?int $adminId = null, ?array $adminIdsInTopDept = null): array
|
||
{
|
||
$username = trim($username);
|
||
if ($username === '') {
|
||
throw new ApiException('username is required');
|
||
}
|
||
|
||
$query = DicePlayer::where('username', $username);
|
||
if ($adminIdsInTopDept !== null && !empty($adminIdsInTopDept)) {
|
||
$query->whereIn('admin_id', $adminIdsInTopDept);
|
||
}
|
||
$player = $query->find();
|
||
if ($player) {
|
||
if ((int) ($player->status ?? 1) === 0) {
|
||
throw new ApiException('Account is disabled and cannot log in');
|
||
}
|
||
$hashed = $this->hashPassword($password);
|
||
if ($player->password !== $hashed) {
|
||
throw new ApiException('Wrong password');
|
||
}
|
||
$currentCoin = (float) $player->coin;
|
||
$player->coin = $currentCoin + $coin;
|
||
$player->save();
|
||
} else {
|
||
$player = new DicePlayer();
|
||
$player->username = $username;
|
||
$player->phone = $username;
|
||
$player->password = $this->hashPassword($password);
|
||
$player->status = self::STATUS_NORMAL;
|
||
$player->coin = $coin;
|
||
if ($adminId !== null && $adminId > 0) {
|
||
$player->admin_id = $adminId;
|
||
}
|
||
$player->save();
|
||
}
|
||
|
||
$exp = (int) config('api.session_expire', 604800);
|
||
$tokenResult = JwtToken::generateToken([
|
||
'id' => (int) $player->id,
|
||
'username' => $username,
|
||
'plat' => 'api_login',
|
||
'access_exp' => $exp,
|
||
]);
|
||
$token = $tokenResult['access_token'];
|
||
UserCache::setSessionByUsername($username, $token);
|
||
|
||
$userArr = $player->hidden(['password', 'lottery_config_id', 't1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight'])->toArray();
|
||
UserCache::setUser((int) $player->id, $userArr);
|
||
UserCache::setPlayerByUsername($username, $userArr);
|
||
|
||
$baseUrl = rtrim(config('api.login_url_base', 'https://127.0.0.1:6777'), '/');
|
||
$lang = in_array($lang, ['chs', 'en'], true) ? $lang : 'chs';
|
||
$tokenInUrl = str_replace('%3D', '=', urlencode($token));
|
||
$url = $baseUrl . '?token=' . $tokenInUrl . '&lang=' . $lang;
|
||
|
||
return [
|
||
'url' => $url,
|
||
'token' => $token,
|
||
'lang' => $lang,
|
||
'user_id' => (int) $player->id,
|
||
'user' => $userArr,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 从 JWT 中解析 username(仅解码 payload,不校验签名与过期,用于退出时清除会话)
|
||
*/
|
||
public static function getUsernameFromJwtPayload(string $token): ?string
|
||
{
|
||
$parts = explode('.', $token);
|
||
if (count($parts) !== 3) {
|
||
return null;
|
||
}
|
||
$payload = base64_decode(strtr($parts[1], '-_', '+/'), true);
|
||
if ($payload === false) {
|
||
return null;
|
||
}
|
||
$data = json_decode($payload, true);
|
||
if (!is_array($data)) {
|
||
return null;
|
||
}
|
||
$extend = $data['extend'] ?? $data;
|
||
$username = $extend['username'] ?? null;
|
||
return $username !== null ? trim((string) $username) : null;
|
||
}
|
||
|
||
/**
|
||
* 从 Redis 获取用户信息(key = base64(user_id)),未命中则查 DicePlayer 并回写缓存
|
||
*/
|
||
public static function getCachedUser(int $userId): array
|
||
{
|
||
$cached = UserCache::getUser($userId);
|
||
if (!empty($cached)) {
|
||
return $cached;
|
||
}
|
||
$user = DicePlayer::find($userId);
|
||
if (!$user) {
|
||
return [];
|
||
}
|
||
$arr = $user->hidden(['password'])->toArray();
|
||
UserCache::setUser($userId, $arr);
|
||
return $arr;
|
||
}
|
||
}
|