Files
zyt/server/app/common/service/qywx/QywxPromotionOpenWorkService.php
T
2026-08-05 11:08:02 +08:00

387 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use EasyWeChat\OpenWork\Application;
use EasyWeChat\OpenWork\Message;
use RuntimeException;
use think\facade\Db;
use think\facade\Log;
/** 企业微信服务商授权流程及授权企业凭证管理。 */
class QywxPromotionOpenWorkService
{
public static function configurationStatus(): array
{
$suiteId = self::configString('suite_id');
$required = ['provider_corp_id', 'suite_id', 'suite_secret', 'token', 'aes_key'];
$missing = [];
foreach ($required as $key) {
if (self::configString($key) === '') {
$missing[] = $key;
}
}
if (self::credentialMaterial() === '') {
$missing[] = 'credential_key';
}
$ticketAt = 0;
if ($suiteId !== '' && self::tableExists('qywx_promotion_provider_state')) {
$ticketAt = (int) (Db::name('qywx_promotion_provider_state')
->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;
}
}
}