buildIdentityByCode($code); $auth = $this->findAuth($identity['openid'], $identity['unionid']); if ($auth) { $userId = (int)$auth['user_id']; $user = Db::name('users')->where('id', $userId)->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('小程序微信已绑定账号不存在或已停用'); } $this->syncAuth($userId, $identity, date('Y-m-d H:i:s'), (int)$auth['id']); return array_merge([ 'status' => 'logged_in', ], $this->issueToken($userId, $request)); } return [ 'status' => 'need_bind', 'bind_ticket' => $this->createBindTicket($identity), 'expire_seconds' => self::BIND_TICKET_TTL, 'profile' => [ 'nickname' => '', 'avatar' => '', ], ]; } public function bindMobile(string $bindTicket, string $mobile, string $code, Request $request): array { $identity = $this->verifyBindTicket($bindTicket); $mobile = $this->normalizeMobile($mobile); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { $user = Db::name('users')->where('mobile', $mobile)->lock(true)->find(); if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号已停用,无法绑定小程序微信'); } $this->assertIdentityAvailable($identity, $user ? (int)$user['id'] : null); $this->verifyLoginCode($mobile, $code, $now); if (!$user) { $userId = (int)Db::name('users')->insertGetId([ 'nickname' => '安心验用户' . substr($mobile, -4), 'avatar' => '', 'mobile' => $mobile, 'password' => '', 'status' => 'enabled', 'last_login_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } else { $userId = (int)$user['id']; } $this->syncMobileAuth($userId, $mobile, $now); $this->syncAuth($userId, $identity, $now); $payload = array_merge([ 'status' => 'logged_in', ], $this->issueToken($userId, $request)); Db::commit(); return $payload; } catch (\Throwable $e) { Db::rollback(); throw $e; } } public function bindOpenid(int $userId, string $code): array { $code = trim($code); if ($userId <= 0) { throw new \RuntimeException('用户登录状态无效'); } if ($code === '') { throw new \RuntimeException('小程序登录 code 不能为空'); } $identity = $this->fetchOpenidByCode($code); $openid = (string)$identity['openid']; $unionid = (string)($identity['unionid'] ?? ''); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { $existing = Db::name('user_auths') ->where('auth_type', self::AUTH_TYPE) ->where('auth_key', $openid) ->lock(true) ->find(); if ($existing && (int)$existing['user_id'] !== $userId) { throw new \RuntimeException('该小程序微信身份已绑定其他账号'); } if ($unionid !== '') { $unionAuth = Db::name('user_auths') ->where('auth_type', self::AUTH_TYPE) ->where('auth_union_id', $unionid) ->lock(true) ->find(); if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) { throw new \RuntimeException('该小程序微信身份已绑定其他账号'); } if (!$existing && $unionAuth) { $existing = $unionAuth; } } $payload = [ 'user_id' => $userId, 'auth_type' => self::AUTH_TYPE, 'auth_key' => $openid, 'auth_open_id' => $openid, 'auth_union_id' => $unionid, 'auth_extra' => json_encode([ 'session_key_present' => ((string)($identity['session_key'] ?? '')) !== '', 'bound_at' => $now, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'updated_at' => $now, ]; if ($existing) { Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload); } else { $payload['created_at'] = $now; Db::name('user_auths')->insert($payload); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } return [ 'openid' => $openid, 'unionid' => $unionid, ]; } public function openidForUser(int $userId): string { if ($userId <= 0) { return ''; } return (string)Db::name('user_auths') ->where('user_id', $userId) ->where('auth_type', self::AUTH_TYPE) ->order('id', 'desc') ->value('auth_open_id'); } private function buildIdentityByCode(string $code): array { $payload = $this->fetchOpenidByCode($code); $openid = trim((string)($payload['openid'] ?? '')); if ($openid === '') { throw new \RuntimeException('微信小程序登录返回缺少 openid'); } $sessionKey = (string)($payload['session_key'] ?? ''); return [ 'openid' => $openid, 'unionid' => trim((string)($payload['unionid'] ?? '')), 'auth_extra' => [ 'source' => 'mini_program_login', 'session_key_present' => $sessionKey !== '', 'authorized_at' => date('Y-m-d H:i:s'), ], ]; } private function findAuth(string $openid, string $unionid, bool $lock = false): ?array { $query = Db::name('user_auths') ->where('auth_type', self::AUTH_TYPE) ->where('auth_key', $openid); if ($lock) { $query->lock(true); } $auth = $query->find(); if ($auth) { return $auth; } if ($unionid === '') { return null; } $query = Db::name('user_auths') ->where('auth_type', self::AUTH_TYPE) ->where('auth_union_id', $unionid) ->order('id', 'asc'); if ($lock) { $query->lock(true); } $auth = $query->find(); return $auth ?: null; } private function assertIdentityAvailable(array $identity, ?int $allowedUserId): void { $auth = $this->findAuth((string)$identity['openid'], (string)$identity['unionid'], true); if ($auth && ($allowedUserId === null || (int)$auth['user_id'] !== $allowedUserId)) { throw new \RuntimeException('该小程序微信身份已绑定其他账号'); } } private function syncAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void { $openid = trim((string)($identity['openid'] ?? '')); $unionid = trim((string)($identity['unionid'] ?? '')); if ($openid === '') { throw new \RuntimeException('小程序 openid 不能为空'); } $existing = null; if ($preferredAuthId) { $existing = Db::name('user_auths')->where('id', $preferredAuthId)->find(); } if (!$existing) { $existing = $this->findAuth($openid, $unionid); } if ($existing && (int)$existing['user_id'] !== $userId) { throw new \RuntimeException('该小程序微信身份已绑定其他账号'); } $payload = [ 'user_id' => $userId, 'auth_type' => self::AUTH_TYPE, 'auth_key' => $openid, 'auth_open_id' => $openid, 'auth_union_id' => $unionid, 'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'updated_at' => $now, ]; if ($existing) { Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload); return; } $payload['created_at'] = $now; Db::name('user_auths')->insert($payload); } private function syncMobileAuth(int $userId, string $mobile, string $now): void { $existing = Db::name('user_auths') ->where('auth_type', 'mobile') ->where('auth_key', $mobile) ->find(); $payload = [ 'user_id' => $userId, 'auth_type' => 'mobile', 'auth_key' => $mobile, 'auth_open_id' => '', 'auth_union_id' => '', 'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE), 'updated_at' => $now, ]; if ($existing) { Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload); return; } $payload['created_at'] = $now; Db::name('user_auths')->insert($payload); } private function verifyLoginCode(string $mobile, string $code, string $now): void { $code = trim($code); if (!preg_match('/^\d{6}$/', $code)) { throw new \RuntimeException('验证码格式不正确'); } $record = Db::name('sms_code_logs') ->where('mobile', $mobile) ->where('scene', 'login') ->whereIn('send_status', ['success', 'mock']) ->whereNull('used_at') ->order('id', 'desc') ->find(); if (!$record) { throw new \RuntimeException('验证码不存在或已失效'); } if (strtotime((string)$record['expire_time']) < time()) { throw new \RuntimeException('验证码已过期,请重新获取'); } if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) { throw new \RuntimeException('验证码错误'); } Db::name('sms_code_logs')->where('id', (int)$record['id'])->update([ 'used_at' => $now, 'updated_at' => $now, ]); } private function issueToken(int $userId, Request $request): array { $user = Db::name('users')->where('id', $userId)->find(); if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { throw new \RuntimeException('账号不存在或已停用'); } $token = bin2hex(random_bytes(24)); $tokenHash = hash('sha256', $token); $now = date('Y-m-d H:i:s'); $expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600); Db::name('user_api_tokens')->where('user_id', $userId)->delete(); Db::name('user_api_tokens')->insert([ 'user_id' => $userId, 'token_hash' => $tokenHash, 'auth_type' => self::AUTH_TYPE, 'expire_time' => $expireTime, 'last_active_at' => $now, 'last_ip' => $request->getRealIp(), 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), 'created_at' => $now, 'updated_at' => $now, ]); Db::name('users')->where('id', $userId)->update([ 'last_login_at' => $now, 'updated_at' => $now, ]); return [ 'token' => $token, 'user_info' => $this->userInfo($userId), ]; } private function userInfo(int $userId): array { $user = Db::name('users')->where('id', $userId)->find(); return [ 'id' => (int)($user['id'] ?? 0), 'nickname' => $user['nickname'] ?: '安心验用户', 'mobile' => $user['mobile'] ?? '', 'avatar' => $user['avatar'] ?? '', 'status' => $user['status'] ?? 'enabled', 'password_set' => ((string)($user['password'] ?? '')) !== '', ]; } private function createBindTicket(array $identity): string { $now = time(); $payload = [ 'typ' => 'wechat_mini_program_bind', 'openid' => (string)$identity['openid'], 'unionid' => (string)($identity['unionid'] ?? ''), 'auth_extra' => $identity['auth_extra'] ?? [], 'iat' => $now, 'exp' => $now + self::BIND_TICKET_TTL, 'nonce' => bin2hex(random_bytes(8)), ]; $body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $signature = hash_hmac('sha256', $body, $this->ticketSecret()); return $body . '.' . $signature; } private function verifyBindTicket(string $ticket): array { $ticket = trim($ticket); if ($ticket === '' || strpos($ticket, '.') === false) { throw new \RuntimeException('小程序绑定凭证无效,请重新授权'); } [$body, $signature] = explode('.', $ticket, 2); $expected = hash_hmac('sha256', $body, $this->ticketSecret()); if (!hash_equals($expected, strtolower($signature))) { throw new \RuntimeException('小程序绑定凭证签名无效,请重新授权'); } $decoded = json_decode($this->base64UrlDecode($body), true); if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_mini_program_bind') { throw new \RuntimeException('小程序绑定凭证无效,请重新授权'); } if ((int)($decoded['exp'] ?? 0) < time()) { throw new \RuntimeException('小程序绑定凭证已过期,请重新授权'); } $openid = trim((string)($decoded['openid'] ?? '')); if ($openid === '') { throw new \RuntimeException('小程序绑定凭证缺少 openid,请重新授权'); } return [ 'openid' => $openid, 'unionid' => trim((string)($decoded['unionid'] ?? '')), 'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [], ]; } private function fetchOpenidByCode(string $code): array { if (str_starts_with($code, 'mock_mp_')) { return [ 'openid' => 'mock_mp_openid_' . substr($code, 8), 'unionid' => '', 'session_key' => 'mock_session_key', ]; } $appId = $this->systemConfig('mini_program', 'app_id'); $appSecret = $this->systemConfig('mini_program', 'app_secret'); if ($appId === '' || $appSecret === '') { throw new \RuntimeException('小程序 AppID 或 AppSecret 未配置'); } $url = 'https://api.weixin.qq.com/sns/jscode2session?' . http_build_query([ 'appid' => $appId, 'secret' => $appSecret, 'js_code' => $code, 'grant_type' => 'authorization_code', ]); $payload = $this->wechatApiGet($url); $openid = trim((string)($payload['openid'] ?? '')); if ($openid === '') { throw new \RuntimeException('微信小程序登录返回缺少 openid'); } return [ 'openid' => $openid, 'unionid' => trim((string)($payload['unionid'] ?? '')), 'session_key' => (string)($payload['session_key'] ?? ''), ]; } private function wechatApiGet(string $url): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 4, ]); $response = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new \RuntimeException('微信小程序登录换取 openid 失败:' . $error); } if ($httpStatus < 200 || $httpStatus >= 300) { throw new \RuntimeException('微信小程序登录接口 HTTP 状态异常:' . $httpStatus); } $payload = json_decode((string)$response, true); if (!is_array($payload)) { throw new \RuntimeException('微信小程序登录接口返回格式异常'); } $errcode = (int)($payload['errcode'] ?? 0); if ($errcode !== 0) { throw new \RuntimeException((string)($payload['errmsg'] ?? '微信小程序登录接口返回错误')); } return $payload; } private function systemConfig(string $group, string $key): string { return trim((string)Db::name('system_configs') ->where('config_group', $group) ->where('config_key', $key) ->value('config_value')); } private function normalizeMobile(string $mobile): string { $mobile = preg_replace('/\D+/', '', $mobile) ?: ''; if (!preg_match('/^1\d{10}$/', $mobile)) { throw new \RuntimeException('请输入正确的手机号'); } return $mobile; } private function codeHash(string $mobile, string $scene, string $code): string { return hash('sha256', implode('|', [$mobile, $scene, $code])); } private function ticketSecret(): string { $seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? '')); if ($seed === '') { $seed = $this->systemConfig('mini_program', 'app_secret'); } if ($seed === '') { $seed = 'anxinyan-mini-program-auth-secret-key'; } return hash('sha256', $seed, true); } private function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private function base64UrlDecode(string $value): string { $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } $decoded = base64_decode(strtr($value, '-_', '+/'), true); return is_string($decoded) ? $decoded : ''; } }