This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace app\mcp\service;
use app\adminapi\logic\LoginLogic;
use app\common\model\auth\Admin;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\Request;
/**
* AI 授权:用后台账号密码一次性换取只读令牌;每次调用实时校验令牌与账号状态。
* 独立于后台登录会话(zyt_admin_session),不会挤掉浏览器、医生工作站或企微客服端的登录。
*/
class GrantService
{
public const STATUS_ACTIVE = 1;
public const STATUS_REVOKED = 2;
public const STATUS_EXPIRED = 3;
/**
* 校验账号密码及各项门禁,通过后签发令牌。失败抛 McpException(reason 见接口约定)。
*/
public static function issue(array $input, string $ip): array
{
$account = trim((string) ($input['account'] ?? ''));
$password = (string) ($input['password'] ?? '');
$client = substr(trim((string) ($input['client'] ?? 'xingzhi')), 0, 32) ?: 'xingzhi';
$instance = substr(trim((string) ($input['client_instance'] ?? '')), 0, 64);
$label = mb_substr(trim((string) ($input['label'] ?? '')), 0, 100);
if ($account === '' || $password === '' || mb_strlen($account) > 64 || strlen($password) > 128) {
throw new McpException('请输入正确的账号和密码', 'invalid_request');
}
if (!RateLimiter::hit('grant_ip_' . md5($ip), McpConfig::grantAttemptsPerIp(), 600)) {
throw new McpException('尝试次数过多,请稍后再试', 'locked');
}
$lockKey = 'ai_mcp_grant_fail_' . md5(mb_strtolower($account));
$failures = (int) Cache::get($lockKey, 0);
if ($failures >= McpConfig::lockFailures()) {
throw new McpException('密码连续' . McpConfig::lockFailures() . '次错误,请' . McpConfig::lockMinutes() . '分钟后重试', 'locked');
}
$admin = Admin::where('account', '=', $account)->findOrEmpty();
$salt = (string) Config::get('project.unique_identification');
$ok = !$admin->isEmpty() && (string) $admin['password'] !== ''
&& hash_equals((string) $admin['password'], create_password($password, $salt));
if (!$ok) {
Cache::set($lockKey, $failures + 1, McpConfig::lockMinutes() * 60);
// 账号不存在与密码错误给同样的提示,避免被用来探测账号
throw new McpException('账号或密码错误', 'invalid_credentials');
}
Cache::delete($lockKey);
self::assertAdminUsable($admin);
if (McpConfig::requirePasswordChanged() && array_key_exists('is_paw', $admin->getData()) && (int) $admin['is_paw'] !== 1) {
throw new McpException('请先在甄养堂后台修改初始密码,再绑定 AI 助手', 'need_change_password');
}
$now = time();
$token = TokenService::generate();
$expire = $now + McpConfig::tokenTtlDays() * 86400;
Db::startTrans();
try {
// 同一客户端实例重新绑定时,旧授权自动作废
Db::name('ai_grant')
->where(['admin_id' => $admin['id'], 'client' => $client, 'client_instance' => $instance, 'status' => self::STATUS_ACTIVE])
->update(['status' => self::STATUS_REVOKED, 'revoke_time' => $now, 'revoke_reason' => 'rebind', 'update_time' => $now]);
$grantId = (int) Db::name('ai_grant')->insertGetId([
'admin_id' => $admin['id'],
'token_hash' => TokenService::hash($token),
'token_prefix' => TokenService::displayPrefix($token),
'client' => $client,
'client_instance' => $instance,
'label' => $label,
'scopes' => 'zyt.read',
'pwd_fp' => self::passwordFingerprint($admin),
'status' => self::STATUS_ACTIVE,
'expire_time' => $expire,
'idle_days' => McpConfig::tokenIdleDays(),
'last_used_time' => $now,
'last_used_ip' => $ip,
'created_ip' => $ip,
'create_time' => $now,
'update_time' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
$identity = new Identity(self::find($grantId), $admin);
return [
'grant_id' => $grantId,
'token' => $token,
'token_prefix' => TokenService::displayPrefix($token),
'expire_at' => $expire,
'idle_days' => McpConfig::tokenIdleDays(),
'admin' => $identity->publicProfile(),
];
}
/**
* 按 Bearer 令牌识别调用人。令牌无效、过期、闲置超期、账号停用/删除/改密、失去 AI 权限时抛 401。
*/
public static function authenticate(Request $request): Identity
{
$token = TokenService::fromRequest($request);
if ($token === '') {
throw McpException::unauthorized('缺少有效的授权令牌');
}
$grant = Db::name('ai_grant')->where('token_hash', TokenService::hash($token))->find();
if (!$grant || (int) $grant['status'] !== self::STATUS_ACTIVE) {
throw McpException::unauthorized();
}
$now = time();
$idleLimit = (int) $grant['last_used_time'] + (int) $grant['idle_days'] * 86400;
if ((int) $grant['expire_time'] <= $now || $idleLimit <= $now) {
self::close((int) $grant['id'], self::STATUS_EXPIRED, 'expired');
throw McpException::unauthorized('授权已过期,请在行知重新绑定甄养堂账号', 'expired');
}
$admin = Admin::where('id', '=', $grant['admin_id'])->findOrEmpty();
if ($admin->isEmpty()) {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_deleted');
throw McpException::unauthorized('甄养堂账号已删除');
}
if (!hash_equals((string) $grant['pwd_fp'], self::passwordFingerprint($admin))) {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'password_changed');
throw McpException::unauthorized('甄养堂账号密码已修改,请重新绑定', 'password_changed');
}
try {
self::assertAdminUsable($admin);
} catch (McpException $e) {
if ($e->reason === 'disabled') {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_disabled');
}
throw new McpException($e->getMessage(), $e->reason, 401);
}
$ip = $request->ip();
if ($now - (int) $grant['last_used_time'] >= 60 || $grant['last_used_ip'] !== $ip) {
Db::name('ai_grant')->where('id', $grant['id'])->update(['last_used_time' => $now, 'last_used_ip' => $ip, 'update_time' => $now]);
}
return new Identity($grant, $admin);
}
public static function find(int $grantId): array
{
return Db::name('ai_grant')->where('id', $grantId)->find() ?: [];
}
public static function close(int $grantId, int $status, string $reason, int $by = 0): void
{
$now = time();
Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([
'status' => $status,
'revoke_time' => $now,
'revoke_by' => $by,
'revoke_reason' => substr($reason, 0, 64),
'update_time' => $now,
]);
}
public static function publicGrant(array $grant): array
{
return [
'grant_id' => (int) $grant['id'],
'expire_at' => (int) $grant['expire_time'],
'idle_days' => (int) $grant['idle_days'],
'last_used_at' => (int) $grant['last_used_time'],
];
}
/** 停用、企微强制绑定、AI 权限点:签发和每次调用都检查 */
private static function assertAdminUsable(Admin $admin): void
{
if ((int) $admin['disable'] === 1) {
throw new McpException('甄养堂账号已停用', 'disabled');
}
if (LoginLogic::adminMustBindWorkWechat(['root' => $admin['root'], 'work_wechat_userid' => $admin['work_wechat_userid'] ?? ''])) {
throw new McpException('请先在甄养堂后台绑定企业微信,再使用 AI 助手', 'need_bind_wecom');
}
if ((int) $admin['root'] !== 1) {
$perm = PermissionService::normalize('ai.mcp/access');
if (!PermissionService::isRegistered('ai.mcp/access') || !isset(PermissionService::adminPerms((int) $admin['id'])[$perm])) {
throw new McpException('该账号未开通“AI 助手查询”权限,请联系甄养堂管理员', 'no_ai_permission');
}
}
}
/** 密码指纹:改密后与签发时不一致,授权随即失效(不需要修改后台任何改密代码) */
private static function passwordFingerprint(Admin $admin): string
{
return hash('sha256', $admin['id'] . ':' . (string) $admin['password']);
}
}