where('suite_id', $suiteId) ->value('ticket_received_at') ?? 0); } return [ 'enabled' => (bool) config('qywx_promotion.enabled', false), 'configured' => $missing === [], 'ready' => (bool) config('qywx_promotion.enabled', false) && $missing === [] && $ticketAt > 0, 'missing' => $missing, 'suite_id_masked' => self::mask($suiteId), 'ticket_received_at' => $ticketAt, ]; } public static function authorizationUrl(int $adminId, string $redirectUri): string { self::assertReady(); $redirectUri = self::configuredRedirectUri($redirectUri); if ($redirectUri === '') { throw new RuntimeException('无法生成企业微信授权回调地址'); } $app = self::application(); $suiteAccessToken = $app->getSuiteAccessToken()->getToken(); $response = $app->getHttpClient()->request('GET', 'cgi-bin/service/get_pre_auth_code', [ 'query' => ['suite_access_token' => $suiteAccessToken], ])->toArray(false); $preAuthCode = trim((string) ($response['pre_auth_code'] ?? '')); if ($preAuthCode === '') { throw new RuntimeException('获取企业微信预授权码失败:' . (string) ($response['errmsg'] ?? '未知错误')); } return 'https://open.work.weixin.qq.com/3rdapp/install?' . http_build_query([ 'suite_id' => self::configString('suite_id'), 'pre_auth_code' => $preAuthCode, 'redirect_uri' => $redirectUri, 'state' => self::makeState($adminId), ], '', '&', PHP_QUERY_RFC3986); } public static function consumeAuthorizationCallback(string $authCode, string $state): array { $adminId = self::verifyState($state); if ($authCode === '') { throw new RuntimeException('企业微信未返回临时授权码'); } return self::exchangePermanentCode($authCode, $adminId); } public static function exchangePermanentCode(string $authCode, int $adminId = 0): array { self::assertConfigured(); $app = self::application(); $suiteAccessToken = $app->getSuiteAccessToken()->getToken(); $response = $app->getHttpClient()->request('POST', 'cgi-bin/service/get_permanent_code', [ 'query' => ['suite_access_token' => $suiteAccessToken], 'json' => ['auth_code' => $authCode], ])->toArray(false); $permanentCode = trim((string) ($response['permanent_code'] ?? '')); $corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : []; $corpId = trim((string) ($corpInfo['corpid'] ?? '')); if ($permanentCode === '' || $corpId === '') { throw new RuntimeException('换取企业永久授权码失败:' . (string) ($response['errmsg'] ?? '返回信息不完整')); } return self::saveAuthorization($corpId, $permanentCode, $response, $adminId); } public static function verifyAccount(int $accountId): array { self::assertConfigured(); $row = Db::name('qywx_promotion_account')->where('id', $accountId)->whereNull('delete_time')->find(); if (!$row) { throw new RuntimeException('授权企业不存在'); } $permanentCode = QywxPromotionCredentialCipher::decrypt((string) ($row['permanent_code_cipher'] ?? '')); $authorization = self::application()->getAuthorization((string) $row['corp_id'], $permanentCode)->toArray(); self::saveAuthorization((string) $row['corp_id'], $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0)); return ['id' => $accountId, 'verified_at' => time()]; } public static function serveProviderCallback() { self::assertConfigured(); $app = self::application(); $server = $app->getServer(); $server->handleAuthCreated(function (Message $message, \Closure $next) { $authCode = trim((string) ($message['AuthCode'] ?? '')); if ($authCode !== '') { try { self::exchangePermanentCode($authCode, 0); } catch (\Throwable $e) { Log::error('企微推广 create_auth 处理失败:' . $e->getMessage(), ['exception' => $e]); } } return $next($message); }); $server->handleAuthChanged(function (Message $message, \Closure $next) { $corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? '')); if ($corpId !== '') { try { self::refreshByCorpId($corpId); } catch (\Throwable $e) { Log::error('企微推广 change_auth 处理失败:' . $e->getMessage(), ['exception' => $e]); } } return $next($message); }); $server->handleAuthCancelled(function (Message $message, \Closure $next) { $corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? '')); if ($corpId !== '') { Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->update([ 'auth_status' => 0, 'update_time' => time(), ]); } return $next($message); }); return $server->serve(); } public static function configuredRedirectUri(string $fallback): string { return self::configString('redirect_uri') ?: trim($fallback); } public static function configuredAdminReturnUrl(string $fallback): string { return self::configString('admin_return_url') ?: trim($fallback); } public static function isAllowedPromotionUrl(string $url, bool $allowEmpty = false): bool { $url = trim($url); if ($url === '') { return $allowEmpty; } $parts = parse_url($url); if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') { return false; } $host = strtolower(trim((string) ($parts['host'] ?? ''))); if ($host === '') { return false; } foreach ((array) config('qywx_promotion.allowed_link_hosts', []) as $allowed) { $allowed = strtolower(trim((string) $allowed)); if ($allowed !== '' && ($host === $allowed || str_ends_with($host, '.' . $allowed))) { return true; } } return false; } private static function application(): Application { $app = new Application([ 'corp_id' => self::configString('provider_corp_id'), 'provider_secret' => '', 'suite_id' => self::configString('suite_id'), 'suite_secret' => self::configString('suite_secret'), 'token' => self::configString('token'), 'aes_key' => self::configString('aes_key'), ]); $app->setSuiteTicket(new QywxPromotionSuiteTicket(self::configString('suite_id'))); return $app; } private static function refreshByCorpId(string $corpId): void { $row = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->find(); if (!$row) { return; } $permanentCode = QywxPromotionCredentialCipher::decrypt((string) $row['permanent_code_cipher']); $authorization = self::application()->getAuthorization($corpId, $permanentCode)->toArray(); self::saveAuthorization($corpId, $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0)); } private static function saveAuthorization(string $corpId, string $permanentCode, array $response, int $adminId): array { $corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : []; $authInfo = is_array($response['auth_info'] ?? null) ? $response['auth_info'] : []; $agents = is_array($authInfo['agent'] ?? null) ? $authInfo['agent'] : []; $agent = is_array($agents[0] ?? null) ? $agents[0] : []; $now = time(); $existing = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->find(); $ownerId = $adminId > 0 ? $adminId : (int) ($existing['owner_admin_id'] ?? 0); $deptId = $ownerId > 0 ? self::primaryDeptId($ownerId) : (int) ($existing['dept_id'] ?? 0); $data = [ 'corp_name' => trim((string) ($corpInfo['corp_name'] ?? $existing['corp_name'] ?? $corpId)), 'permanent_code_cipher' => QywxPromotionCredentialCipher::encrypt($permanentCode), 'agent_id' => trim((string) ($agent['agentid'] ?? $existing['agent_id'] ?? '')), // 授权响应可能包含 permanent_code;数据库元数据中只保留脱敏后的授权信息。 'auth_info_json' => json_encode(self::sanitizeAuthInfo($response), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'auth_status' => 1, 'owner_admin_id' => $ownerId, 'dept_id' => $deptId, 'authorized_at' => (int) ($existing['authorized_at'] ?? 0) ?: $now, 'last_refresh_at' => $now, 'update_time' => $now, 'delete_time' => null, ]; if ($existing) { Db::name('qywx_promotion_account')->where('id', (int) $existing['id'])->update($data); $id = (int) $existing['id']; } else { $data['corp_id'] = $corpId; $data['create_time'] = $now; $id = (int) Db::name('qywx_promotion_account')->insertGetId($data); } return ['id' => $id, 'corp_id' => $corpId, 'corp_name' => $data['corp_name']]; } private static function makeState(int $adminId): string { $payload = self::base64UrlEncode(json_encode([ 'a' => $adminId, 't' => time(), 'n' => bin2hex(random_bytes(8)), ], JSON_THROW_ON_ERROR)); $signature = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true)); return $payload . '.' . $signature; } private static function sanitizeAuthInfo(array $data): array { $sensitiveKeys = ['permanent_code', 'access_token', 'suite_ticket', 'suite_secret', 'provider_secret']; foreach ($data as $key => $value) { if (in_array(strtolower((string) $key), $sensitiveKeys, true)) { unset($data[$key]); continue; } if (is_array($value)) { $data[$key] = self::sanitizeAuthInfo($value); } } return $data; } private static function verifyState(string $state): int { $parts = explode('.', $state, 2); if (count($parts) !== 2) { throw new RuntimeException('企业微信授权 state 无效'); } [$payload, $signature] = $parts; $expected = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true)); if (!hash_equals($expected, $signature)) { throw new RuntimeException('企业微信授权 state 验证失败'); } $data = json_decode(self::base64UrlDecode($payload), true); if (!is_array($data) || time() - (int) ($data['t'] ?? 0) > 1800) { throw new RuntimeException('企业微信授权请求已过期,请重新发起'); } return max(0, (int) ($data['a'] ?? 0)); } private static function assertReady(): void { $status = self::configurationStatus(); if (!$status['enabled']) { throw new RuntimeException('企业微信推广授权尚未启用'); } self::assertConfigured(); if (!$status['ticket_received_at']) { throw new RuntimeException('尚未收到 suite_ticket,请先配置企业微信应用指令回调'); } } private static function assertConfigured(): void { $status = self::configurationStatus(); if (!$status['configured']) { throw new RuntimeException('企业微信服务商配置不完整:' . implode(', ', $status['missing'])); } } private static function stateKey(): string { $key = self::credentialMaterial(); if ($key === '') { throw new RuntimeException('未配置企业微信推广授权签名密钥'); } return hash('sha256', 'qywx-promotion-state|' . $key); } private static function credentialMaterial(): string { return self::configString('credential_key') ?: self::configString('suite_secret'); } private static function configString(string $key): string { return trim((string) config('qywx_promotion.' . $key, '')); } private static function primaryDeptId(int $adminId): int { return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0); } private static function mask(string $value): string { $length = strlen($value); if ($length <= 8) { return $value === '' ? '' : str_repeat('*', $length); } return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4); } private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private static function base64UrlDecode(string $value): string { $value = strtr($value, '-_', '+/'); $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } return (string) base64_decode($value, true); } private static function tableExists(string $table): bool { try { return Db::query("SHOW TABLES LIKE '" . config('database.connections.mysql.prefix', '') . $table . "'") !== []; } catch (\Throwable) { return false; } } }