更新
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 企业微信推广凭证加密器:密钥仅来自服务器配置,密文可安全落库。 */
|
||||
class QywxPromotionCredentialCipher
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('企业微信授权凭证加密失败');
|
||||
}
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'v' => 1,
|
||||
'iv' => base64_encode($iv),
|
||||
'tag' => base64_encode($tag),
|
||||
'data' => base64_encode($cipher),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function decrypt(string $payload): string
|
||||
{
|
||||
if ($payload === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$json = base64_decode($payload, true);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$iv = base64_decode((string) ($data['iv'] ?? ''), true);
|
||||
$tag = base64_decode((string) ($data['tag'] ?? ''), true);
|
||||
$cipher = base64_decode((string) ($data['data'] ?? ''), true);
|
||||
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('企业微信授权凭证解密失败,请检查 CREDENTIAL_KEY 是否发生变更');
|
||||
}
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$material = trim((string) config('qywx_promotion.credential_key', ''));
|
||||
if ($material === '') {
|
||||
$material = trim((string) config('qywx_promotion.suite_secret', ''));
|
||||
}
|
||||
if ($material === '') {
|
||||
throw new RuntimeException('未配置企业微信推广凭证加密密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', $material, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
class QywxPromotionRedirectService
|
||||
{
|
||||
/** @return array{url:string,link_id:int}|null */
|
||||
public static function pick(string $publicKey, array $context = []): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $publicKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($publicKey, $context): ?array {
|
||||
$pool = Db::name('qywx_promotion_pool')
|
||||
->where('public_key', $publicKey)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->where('l.pool_id', (int) $pool['id'])
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
|
||||
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
|
||||
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
|
||||
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
|
||||
->field('l.*')
|
||||
->lock(true)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$selected = self::weightedRandom($links);
|
||||
if (!$selected) {
|
||||
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
|
||||
if (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
|
||||
return ['url' => $fallback, 'link_id' => 0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$sameDay = (string) ($selected['today_date'] ?? '') === $today;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $selected['id'])->update([
|
||||
'click_count' => (int) ($selected['click_count'] ?? 0) + 1,
|
||||
'today_count' => $sameDay ? (int) ($selected['today_count'] ?? 0) + 1 : 1,
|
||||
'today_date' => $today,
|
||||
'last_click_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_pool')->where('id', (int) $pool['id'])->inc('click_count')->update([
|
||||
'update_time' => $now,
|
||||
]);
|
||||
self::recordClick((int) $pool['id'], (int) $selected['id'], $context, $now);
|
||||
|
||||
return ['url' => (string) $selected['wecom_url'], 'link_id' => (int) $selected['id']];
|
||||
});
|
||||
}
|
||||
|
||||
public static function poolExists(string $publicKey): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
|
||||
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $links */
|
||||
private static function weightedRandom(array $links): ?array
|
||||
{
|
||||
if ($links === []) {
|
||||
return null;
|
||||
}
|
||||
$total = array_sum(array_map(static fn (array $row): int => max(1, (int) ($row['weight'] ?? 1)), $links));
|
||||
$needle = random_int(1, max(1, $total));
|
||||
foreach ($links as $link) {
|
||||
$needle -= max(1, (int) ($link['weight'] ?? 1));
|
||||
if ($needle <= 0) {
|
||||
return $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $links[array_key_last($links)];
|
||||
}
|
||||
|
||||
private static function recordClick(int $poolId, int $linkId, array $context, int $now): void
|
||||
{
|
||||
$source = self::safeSource((string) ($context['source_url'] ?? ''));
|
||||
$ip = trim((string) ($context['ip'] ?? ''));
|
||||
$salt = (string) config('qywx_promotion.credential_key', '') ?: (string) config('qywx_promotion.suite_secret', '');
|
||||
Db::name('qywx_promotion_click_log')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'link_id' => $linkId,
|
||||
'source_url' => $source,
|
||||
'referer' => self::safeSource((string) ($context['referer'] ?? '')),
|
||||
'user_agent' => mb_substr((string) ($context['user_agent'] ?? ''), 0, 500),
|
||||
// 未配置服务端密钥时不落 IP,避免使用公开固定盐形成可枚举标识。
|
||||
'ip_hash' => $ip === '' || $salt === '' ? '' : hash_hmac('sha256', $ip, $salt),
|
||||
'click_date' => date('Y-m-d', $now),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function safeSource(string $url): string
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
if (!is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_substr($scheme . '://' . $host . (string) ($parts['path'] ?? ''), 0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 将企业微信每十分钟推送的 suite_ticket 加密持久化,避免进程/缓存重启后丢失。 */
|
||||
class QywxPromotionSuiteTicket implements SuiteTicket
|
||||
{
|
||||
public function __construct(private readonly string $suiteId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTicket(): string
|
||||
{
|
||||
$cipher = (string) (Db::name('qywx_promotion_provider_state')
|
||||
->where('suite_id', $this->suiteId)
|
||||
->value('suite_ticket_cipher') ?? '');
|
||||
if ($cipher === '') {
|
||||
throw new RuntimeException('No suite_ticket found. 请先在企业微信服务商后台配置并验证应用指令回调。');
|
||||
}
|
||||
|
||||
return QywxPromotionCredentialCipher::decrypt($cipher);
|
||||
}
|
||||
|
||||
public function setTicket(string $ticket): static
|
||||
{
|
||||
$now = time();
|
||||
$cipher = QywxPromotionCredentialCipher::encrypt($ticket);
|
||||
$exists = Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->find();
|
||||
if ($exists) {
|
||||
Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->update([
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('qywx_promotion_provider_state')->insert([
|
||||
'suite_id' => $this->suiteId,
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user