首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
24
篇与
» 后端技术
的结果
2026-05-10
Redis 缓存实战:穿透、击穿、雪崩三大问题解决方案
前言Redis 作为缓存使用时,会遇到三大经典问题:缓存穿透、缓存击穿和缓存雪崩。这三个问题在实际生产中频繁出现,处理不当可能导致数据库被压垮甚至服务不可用。本文将深入分析每个问题的成因并给出完整解决方案。一、缓存穿透1.1 问题描述用户请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。常见于恶意攻击(用不存在的 ID 大量请求)或业务异常数据。请求 → 缓存MISS → 数据库MISS → 返回空 ↑ │ └──── 不断重复 ──────────────────────┘1.2 危害数据库承受大量无效查询如果是恶意攻击,可能打垮数据库浪费服务器资源1.3 解决方案方案一:缓存空值function getUser($id) { $cacheKey = "user:{$id}"; $data = $redis->get($cacheKey); if ($data !== false) { // 命中缓存(包括空值缓存) return $data === 'NULL' ? null : json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]); if (empty($data)) { // 缓存空值,设置较短过期时间(60秒) $redis->setex($cacheKey, 60, 'NULL'); return null; } $redis->setex($cacheKey, 3600, json_encode($data)); return $data; }优点:实现简单缺点:可能缓存大量空值,占用内存;数据后来新增了也不会被感知(除非过期)方案二:布隆过滤器(Bloom Filter)// 系统初始化时,将所有存在的ID加入布隆过滤器 function initBloomFilter() { $redis->del('bloom:user_ids'); $userIds = $db->query("SELECT id FROM users"); foreach ($userIds as $id) { // 使用多个哈希函数 bloomAdd('bloom:user_ids', $id); } } // 检查ID是否可能存在 function bloomAdd($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { $redis->setBit($key, $h, 1); } } function bloomExists($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { if ($redis->getBit($key, $h) == 0) { return false; // 一定不存在 } } return true; // 可能存在(有误判率) } // 请求处理 function getUser($id) { // 先过布隆过滤器 if (!bloomExists('bloom:user_ids', $id)) { return null; // 一定不存在,直接返回 } // 再查缓存和数据库 // ... }方案三:接口层参数校验// 在入口处拦截非法参数 function getUser($id) { // ID 必须是正整数 if (!is_numeric($id) || $id <= 0 || $id > PHP_INT_MAX) { throw new InvalidArgumentException('Invalid user ID'); } // 其他业务规则校验 if (strlen($id) > 10) { throw new InvalidArgumentException('ID too long'); } // 正常流程... }二、缓存击穿2.1 问题描述某个热点 key 突然过期(或被删除),大量并发请求同时访问这个 key,全部穿透到数据库。热点key过期 │ ├──→ 请求1 → 缓存MISS → 查DB ──┐ ├──→ 请求2 → 缓存MISS → 查DB ──┤ ├──→ 请求3 → 缓存MISS → 查DB ──┤ ├──→ 请求N → 缓存MISS → 查DB ──┤ │ │ └──────── 数据库被压垮 ◄────────┘2.2 解决方案方案一:互斥锁(推荐)function getHotData($key) { $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 获取互斥锁 $lockKey = "lock:{$key}"; $lockValue = uniqid('', true); // SET NX EX 尝试加锁 $locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]); if ($locked) { try { // 再次检查缓存(双重检查) $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM hot_table WHERE key_name = ?", [$key]); // 写入缓存 $redis->setex($key, 3600, json_encode($data)); return $data; } finally { // 释放锁(Lua脚本保证原子性) $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; $redis->eval($lua, [$lockKey, $lockValue], 1); } } else { // 没拿到锁,短暂等待后重试 usleep(100000); // 100ms return getHotData($key); // 递归重试 } }方案二:逻辑过期不给 key 设置 TTL,而是在 value 中存储逻辑过期时间。后台异步刷新:function getWithLogicalExpire($key, $expireSeconds) { $data = $redis->get($key); if ($data === false) { return null; // 首次加载,需要手动初始化 } $cached = json_decode($data, true); $now = time(); // 未逻辑过期,直接返回 if ($cached['expire_at'] > $now) { return $cached['data']; } // 已逻辑过期,尝试获取锁刷新 $lockKey = "lock:refresh:{$key}"; $locked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]); if ($locked) { // 后台异步刷新(非阻塞) // 实际项目中可以用队列或协程 go(function() use ($key, $expireSeconds) { $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); $redis->set($key, json_encode([ 'data' => $data, 'expire_at' => time() + $expireSeconds, ])); $redis->del("lock:refresh:{$key}"); }); } // 无论是否刷新,先返回旧数据(不阻塞用户) return $cached['data']; }方案三:永不过期 + 主动更新// 热点数据不设置过期时间 $redis->set('hot:ranking', json_encode($data)); // 不设置 TTL // 数据变更时主动更新缓存 function updateRanking() { $data = $db->query("SELECT * FROM ranking ORDER BY score DESC LIMIT 100"); $redis->set('hot:ranking', json_encode($data)); // 仍然不设置 TTL } // 定时任务刷新 // crontab: */10 * * * * php artisan ranking:refresh三、缓存雪崩3.1 问题描述大量 key 在同一时间集中过期,或者 Redis 宕机,导致大量请求同时打到数据库。大量key同时过期 │ ├──→ 请求1 → MISS → 查DB ──┐ ├──→ 请求2 → MISS → 查DB ──┤ ├──→ 请求3 → MISS → 查DB ──┤ ├──→ 请求N → MISS → 查DB ──┤ │ │ └──── 数据库被压垮 ◄────────┘3.2 与缓存击穿的区别对比缓存击穿缓存雪崩影响范围单个热点 key大量 key原因热点 key 过期大量 key 同时过期 / Redis 宕机量级小大3.3 解决方案方案一:随机过期时间function cacheData($key, $data, $baseTTL = 3600) { // 在基础TTL上加随机偏移,避免同时过期 $randomTTL = $baseTTL + mt_rand(0, 600); // 0~10分钟随机 $redis->setex($key, $randomTTL, json_encode($data)); } // 批量缓存时尤其重要 function batchCache($items) { foreach ($items as $key => $value) { $ttl = 3600 + mt_rand(0, 1800); // 1~1.5小时随机 $redis->setex($key, $ttl, json_encode($value)); } }方案二:多级缓存function getWithMultiLevel($key) { // L1: 本地缓存(进程内) $data = $localCache->get($key); if ($data !== null) { return $data; } // L2: Redis 缓存 $data = $redis->get($key); if ($data !== false) { $localCache->set($key, $data, 60); // 本地缓存60秒 return json_decode($data, true); } // L3: 数据库 $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); if ($data) { $redis->setex($key, 3600 + mt_rand(0, 600), json_encode($data)); $localCache->set($key, $data, 60); } return $data; }方案三:熔断降级function getProductList($categoryId) { try { $data = $redis->get("products:{$categoryId}"); if ($data !== false) { return json_decode($data, true); } // 数据库查询带超时和限流 $data = $db->query("SELECT * FROM products WHERE category_id = ?", [$categoryId], ['timeout' => 1]); $redis->setex("products:{$categoryId}", 3600 + mt_rand(0, 600), json_encode($data)); return $data; } catch (Exception $e) { // 降级:返回默认数据或静态数据 return getDefaultProductList(); } }方案四:Redis 高可用# Redis Sentinel 哨兵模式自动故障转移 sentinel monitor mymaster 192.168.1.10 6379 2 sentinel down-after-milliseconds mymaster 30000 sentinel failover-timeout mymaster 180000 # Redis Cluster 集群模式 # 数据分散在多个节点,单节点宕机不影响整体四、综合对比问题本质核心方案推荐组合缓存穿透查询不存在数据布隆过滤器 + 空值缓存布隆过滤器 + 短TTL空值缓存击穿热点key过期互斥锁 + 双重检查互斥锁 + 逻辑过期缓存雪崩大量key同时过期随机TTL + 多级缓存随机TTL + 熔断降级五、完整防护代码示例class CacheService { private $redis; private $db; private $localCache = []; /** * 安全缓存读取(防穿透+击穿+雪崩) */ public function get($key, $dbQuery, $ttl = 3600) { // 1. 本地缓存 if (isset($this->localCache[$key])) { return $this->localCache[$key]; } // 2. Redis 缓存 $data = $this->redis->get($key); if ($data !== false) { if ($data === 'NULL') return null; $this->localCache[$key] = json_decode($data, true); return $this->localCache[$key]; } // 3. 互斥锁防击穿 $lockKey = "lock:{$key}"; $locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]); if (!$locked) { usleep(100000); // 等待100ms return $this->get($key, $dbQuery, $ttl); // 重试 } try { // 双重检查 $data = $this->redis->get($key); if ($data !== false) { return $data === 'NULL' ? null : json_decode($data, true); } // 4. 查数据库 $data = $dbQuery(); // 5. 缓存(随机TTL防雪崩) $randomTTL = $ttl + mt_rand(0, 300); if ($data === null) { // 空值缓存(短TTL防穿透) $this->redis->setex($key, 60, 'NULL'); } else { $this->redis->setex($key, $randomTTL, json_encode($data)); $this->localCache[$key] = $data; } return $data; } finally { $this->redis->del($lockKey); } } } // 使用 $cache = new CacheService(); $user = $cache->get("user:1001", function() use ($db) { return $db->query("SELECT * FROM users WHERE id = 1001"); }, 3600);总结缓存三大问题的核心防护策略:穿透:布隆过滤器拦截 + 空值缓存兜底击穿:互斥锁保证只有一个请求查DB + 双重检查雪崩:随机TTL打散过期时间 + 多级缓存 + 熔断降级生产环境建议三者组合使用,形成完整的缓存防护体系。
2026年05月10日
6 阅读
0 评论
0 点赞
2026-04-02
Redis 分布式锁实现:从 SET NX 到 Redlock 算法
前言分布式锁是分布式系统中控制资源访问的核心机制。Redis 因其单线程特性和高性能,是实现分布式锁的首选方案。本文将从最简单的实现出发,逐步演进到生产级的 Redlock 算法。一、为什么需要分布式锁1.1 单机锁的问题// 单机环境:PHP文件锁或flock可以解决 $fp = fopen('/tmp/lock.txt', 'w+'); flock($fp, LOCK_EX); // 加锁 // 执行业务逻辑 flock($fp, LOCK_UN); // 释放锁 fclose($fp);但在分布式环境中,请求可能被分配到不同服务器,文件锁无法跨机器生效。1.2 分布式锁的核心要求要求说明互斥性同一时刻只有一个客户端持有锁避免死锁锁必须有过期时间,防止持有者宕机导致永久锁死解铃还须系铃人只有锁的持有者才能释放锁高可用锁服务自身不能成为单点二、Redis 分布式锁演进2.1 版本一:SETNX(初版)// 最简单的实现 $lockKey = 'lock:order:1001'; // 尝试加锁 $redis->setnx($lockKey, 1); // 返回1表示成功,0表示失败 // 设置过期时间(防死锁) $redis->expire($lockKey, 30); // 业务逻辑 processOrder(); // 释放锁 $redis->del($lockKey);致命问题:setnx 和 expire 是两条命令,不保证原子性。如果 setnx 成功后服务崩溃,锁将永远不会过期,导致死锁。2.2 版本二:SET NX EX(推荐基础版)$lockKey = 'lock:order:1001'; $lockValue = uniqid('', true); // 生成唯一标识 // 一条命令完成加锁+过期,原子操作 $result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]); if ($result) { try { // 获取锁成功,执行业务 processOrder(); } finally { // 释放锁 $redis->del($lockKey); } } else { // 获取锁失败 throw new Exception('系统繁忙,请稍后重试'); }改进:SET key value NX EX 30 是原子操作,解决了死锁问题。遗留问题:释放锁时直接 DEL,可能删掉别人的锁。2.3 版本三:锁标识 + Lua 释放(安全版)$lockKey = 'lock:order:1001'; $lockValue = uniqid('', true); // 加锁 $result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]); if (!$result) { throw new Exception('获取锁失败'); } try { processOrder(); } finally { // 用Lua脚本安全释放锁:只有value匹配才删除 $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; $redis->eval($luaScript, [$lockKey, $lockValue], 1); }为什么需要 Lua 脚本?时间线: T1: 客户端A GET lock → value=A T2: 锁过期,自动删除 T3: 客户端B SET lock NX → 成功,value=B T4: 客户端A DEL lock → 删掉了B的锁!Lua 脚本保证了 GET + 判断 + DEL 三步原子执行。2.4 版本四:锁续期(生产可用版)业务执行时间可能超过锁的过期时间,需要后台续期:class RedisLock { private $redis; private $lockKey; private $lockValue; private $ttl = 30; private $renewTimer = null; public function lock($key, $ttl = 30) { $this->lockKey = $key; $this->lockValue = uniqid('', true); $this->ttl = $ttl; // 尝试加锁,最多重试3次 for ($i = 0; $i < 3; $i++) { $result = $this->redis->set( $this->lockKey, $this->lockValue, ['NX', 'EX' => $this->ttl] ); if ($result) { $this->startRenew(); return true; } usleep(100000); // 100ms后重试 } return false; } /** * 后台续期(守护进程/定时器) * 每隔 TTL/3 秒续期一次 */ private function startRenew() { $renewInterval = intval($this->ttl / 3); // 使用Swoole定时器(或PCNTL信号) if (function_exists('swoole_timer_tick')) { $this->renewTimer = swoole_timer_tick( $renewInterval * 1000, function() { $this->renew(); } ); } } private function renew() { $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end "; $this->redis->eval( $luaScript, [$this->lockKey, $this->lockValue, $this->ttl], 1 ); } public function unlock() { // 停止续期 if ($this->renewTimer) { swoole_timer_clear($this->renewTimer); } // 安全释放 $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; $this->redis->eval( $luaScript, [$this->lockKey, $this->lockValue], 1 ); } }2.5 版本五:可重入锁同一线程多次获取同一把锁不应死锁:class ReentrantLock { private $redis; /** * 加锁(可重入) * 使用Hash结构:key=锁名, field=客户端ID, value=重入次数 */ public function lock($lockKey, $clientId, $ttl = 30) { $luaScript = " -- 锁不存在或当前客户端已持有 if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then -- 重入次数+1 redis.call('hincrby', KEYS[1], ARGV[1], 1) -- 设置过期时间 redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end "; return $this->redis->eval( $luaScript, [$lockKey, $clientId, $ttl], 1 ) === 1; } /** * 释放锁(可重入) * 重入次数-1,减到0时删除锁 */ public function unlock($lockKey, $clientId) { $luaScript = " -- 不是锁的持有者 if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil end -- 重入次数-1 local count = redis.call('hincrby', KEYS[1], ARGV[1], -1) if count > 0 then -- 还有重入次数,续期 redis.call('expire', KEYS[1], 30) return count else -- 重入次数归零,删除锁 redis.call('del', KEYS[1]) return 0 end "; return $this->redis->eval( $luaScript, [$lockKey, $clientId], 1 ); } } // 使用 $lock = new ReentrantLock(); $clientId = 'client-' . getmypid(); $lock->lock('lock:order', $clientId); // count=1 $lock->lock('lock:order', $clientId); // count=2(可重入) $lock->unlock('lock:order', $clientId); // count=1 $lock->unlock('lock:order', $clientId); // count=0, 锁删除三、Redlock 算法3.1 为什么需要 Redlock单 Redis 实例的分布式锁有一个问题:如果 Master 宕机,锁数据还没同步到 Slave,新 Master 上没有锁信息,其他客户端可以加锁成功,导致互斥性被破坏。Redlock 由 Redis 作者提出,通过多个独立 Redis 实例投票解决此问题。3.2 算法流程客户端 Redis集群 │ │── 加锁请求 ──→ Node1 ──→ 成功 (1/5) │── 加锁请求 ──→ Node2 ──→ 成功 (2/5) │── 加锁请求 ──→ Node3 ──→ 成功 (3/5) ← 超过半数 │── 加锁请求 ──→ Node4 ──→ 超时 │── 加锁请求 ──→ Node5 ──→ 超时 │ │ 加锁耗时 = 50ms │ 锁TTL = 30s │ 实际有效时间 = 30s - 50ms = 29.95s │ │←── 加锁成功(3/5节点同意)─────步骤:获取当前时间 T1依次向 N 个 Redis 节点请求加锁(相同的 key、value、较短TTL)获取当前时间 T2,计算加锁耗时 = T2 - T1如果加锁成功节点数 ≥ ⌈N/2⌉ + 1 且加锁耗时 < TTL,则加锁成功实际锁有效时间 = TTL - 加锁耗时如果加锁失败,向所有节点发送释放锁请求3.3 PHP 实现class Redlock { private $servers = []; public function __construct(array $redisServers) { foreach ($redisServers as $server) { $redis = new Redis(); $redis->connect($server['host'], $server['port']); if (isset($server['password'])) { $redis->auth($server['password']); } $this->servers[] = $redis; } } public function lock($resource, $ttl = 10000) { $lockValue = bin2hex(random_bytes(16)); $successCount = 0; $startTime = microtime(true) * 1000; // 向所有节点请求加锁 foreach ($this->servers as $redis) { try { $result = $redis->set( $resource, $lockValue, ['NX', 'PX' => $ttl] ); if ($result) { $successCount++; } } catch (Exception $e) { // 节点不可用,继续尝试其他节点 } } $elapsed = microtime(true) * 1000 - $startTime; $quorum = floor(count($this->servers) / 2) + 1; // 判断是否加锁成功 if ($successCount >= $quorum && $elapsed < $ttl) { // 返回锁信息(包含实际有效时间) return [ 'key' => $resource, 'value' => $lockValue, 'validity_time' => $ttl - intval($elapsed), ]; } // 加锁失败,释放所有已加的锁 $this->unlock($resource, $lockValue); return false; } public function unlock($resource, $lockValue) { $luaScript = " if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; foreach ($this->servers as $redis) { try { $redis->eval($luaScript, [$resource, $lockValue], 1); } catch (Exception $e) { // 忽略 } } } } // 使用 $redlock = new Redlock([ ['host' => '192.168.1.10', 'port' => 6379, 'password' => 'pass1'], ['host' => '192.168.1.11', 'port' => 6379, 'password' => 'pass2'], ['host' => '192.168.1.12', 'port' => 6379, 'password' => 'pass3'], ['host' => '192.168.1.13', 'port' => 6379, 'password' => 'pass4'], ['host' => '192.168.1.14', 'port' => 6379, 'password' => 'pass5'], ]); $lock = $redlock->lock('lock:order:1001', 10000); // TTL=10秒 if ($lock) { try { // 执行业务逻辑(注意在 validity_time 内完成) processOrder(); } finally { $redlock->unlock($lock['key'], $lock['value']); } } else { throw new Exception('获取分布式锁失败'); }3.4 Redlock 的争议Martin Kleppmann(《DDIA》作者)曾质疑 Redlock:时钟漂移可能导致锁失效GC 暂停可能导致客户端持有过期锁实际建议:如果对正确性要求极高(如金融场景),用 ZooKeeper 或 etcd如果是普通业务场景(如防重复下单),单 Redis + Lua 足够Redlock 适合需要比单 Redis 更高可靠性,但又不想引入 ZK 的场景四、实战:防重复下单class OrderService { private $redis; /** * 防重复下单 */ public function createOrder($userId, $productId, $quantity) { $lockKey = "lock:order:{$userId}:{$productId}"; $lockValue = uniqid('', true); // 加锁(10秒过期) $locked = $this->redis->set( $lockKey, $lockValue, ['NX', 'EX' => 10] ); if (!$locked) { throw new Exception('请勿重复提交,10秒后重试'); } try { // 1. 检查库存 $stock = $this->redis->get("stock:{$productId}"); if ($stock < $quantity) { throw new Exception('库存不足'); } // 2. 扣减库存 $this->redis->decrBy("stock:{$productId}", $quantity); // 3. 创建订单 $orderId = $this->db->insert('orders', [ 'user_id' => $userId, 'product_id' => $productId, 'quantity' => $quantity, 'status' => 'pending', 'created_at' => date('Y-m-d H:i:s'), ]); // 4. 发送延迟消息(30分钟未支付自动取消) $this->mq->publish('order_timeout', [ 'order_id' => $orderId, ], 1800); return $orderId; } catch (Exception $e) { // 回滚库存 $this->redis->incrBy("stock:{$productId}", $quantity); throw $e; } finally { // 安全释放锁 $lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; $this->redis->eval($lua, [$lockKey, $lockValue], 1); } } }五、各方案对比方案互斥性防死锁容错性性能复杂度SETNX+EXPIRE✅❌❌高低SET NX EX✅✅❌高低SET NX EX + Lua释放✅✅❌高中锁续期✅✅❌高中高可重入锁✅✅❌高高Redlock✅✅✅中高总结Redis 分布式锁的演进路线:SET NX EX 原子加锁 → 基础方案Lua 脚本安全释放 → 必须做后台续期 → 长任务必须做可重入 → 递归/嵌套场景需要Redlock → 对容错性要求高时考虑生产推荐:单 Redis + SET NX EX + Lua 释放 + 续期,覆盖 95% 场景。
2026年04月02日
18 阅读
0 评论
0 点赞
2026-03-29
FastAdmin API 接口开发:前后端分离实践
前言随着前后端分离架构的普及,FastAdmin 的 API 模块越来越重要。本文将系统讲解如何使用 FastAdmin 开发 RESTful API 接口,包括 Token 认证、参数验证、数据返回格式规范以及与前端框架的对接实践。一、API 模块概述FastAdmin 的 API 模块位于 application/api/,专门用于提供接口服务:application/api/ ├── controller/ │ ├── Common.php # 公共控制器 │ ├── Index.php # 首页接口 │ ├── User.php # 用户接口 │ ├── Api.php # 基类控制器 │ └── Demo.php # 示例接口 ├── model/ │ └── User.php # API 用户模型 └── config.php # API 模块配置二、API 基类控制器所有 API 控制器继承自 app\common\controller\Api:<?php // application/common/controller/Api.php namespace app\common\controller; use think\Controller; use app\common\library\Token; use app\common\library\Auth; class Api extends Controller { // 不需要登录的方法 protected $noNeedLogin = ['*']; // 不需要权限验证的方法 protected $noNeedRight = ['*']; // 当前请求的 Token protected $token = ''; // 当前登录用户 protected $auth = null; // 初始化 public function _initialize() { parent::_initialize(); // 跨域处理 $this->checkCors(); // Token 认证 $this->token = $this->request->server('HTTP_TOKEN', $this->request->request('token', '')); // 初始化认证 $this->auth = Auth::instance(); // 检查是否需要登录 $action = strtolower($this->request->action()); if (!in_array($action, array_map('strtolower', $this->noNeedLogin))) { if (!$this->auth->isLogin()) { $this->error(__('Please login first'), null, 401); } } } // 跨域设置 protected function checkCors() { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, GET, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: token, Content-Type, Authorization'); if ($this->request->method() == 'OPTIONS') { $this->success(); } } // 成功返回 protected function success($msg = '', $data = null, $code = 1) { $this->result($msg, $data, $code); } // 失败返回 protected function error($msg = '', $data = null, $code = 0) { $this->result($msg, $data, $code); } // 统一返回格式 protected function result($msg, $data = null, $code = 1) { $result = [ 'code' => $code, 'msg' => $msg, 'time' => time(), 'data' => $data, ]; throw new \think\exception\HttpResponseException( json($result) ); } }统一返回格式{ "code": 1, "msg": "获取成功", "time": 1721800000, "data": { "id": 1, "username": "admin" } }字段说明code1=成功,0=失败msg提示消息time服务器时间戳data业务数据三、用户认证3.1 注册接口<?php // application/api/controller/User.php namespace app\api\controller; use app\common\controller\Api; use app\common\model\User; use app\common\library\Token; use think\Validate; class User extends Api { protected $noNeedLogin = ['register', 'login']; protected $noNeedRight = ['*']; /** * 用户注册 * POST /api/user/register */ public function register() { $username = $this->request->post('username'); $password = $this->request->post('password'); $email = $this->request->post('email'); $mobile = $this->request->post('mobile'); // 参数验证 $validate = new Validate([ 'username' => 'require|length:3,30', 'password' => 'require|length:6,30', 'email' => 'require|email', 'mobile' => 'require|regex:/^1[3-9]\d{9}$/', ], [ 'username.require' => '用户名不能为空', 'username.length' => '用户名长度3-30个字符', 'password.require' => '密码不能为空', 'password.length' => '密码长度6-30个字符', 'email.email' => '邮箱格式不正确', 'mobile.regex' => '手机号格式不正确', ]); $data = [ 'username' => $username, 'password' => $password, 'email' => $email, 'mobile' => $mobile, ]; if (!$validate->check($data)) { $this->error($validate->getError()); } // 检查用户名是否已存在 if (User::where('username', $username)->find()) { $this->error('用户名已存在'); } // 创建用户 $user = new User(); $user->allowField(true)->save([ 'username' => $username, 'password' => \app\common\library\Hash::make($password), 'email' => $email, 'mobile' => $mobile, 'jointime' => time(), 'createtime' => time(), 'updatetime' => time(), ]); // 生成 Token $token = Token::create($user->id, 'user', 86400 * 7); $this->success('注册成功', [ 'token' => $token, 'user' => [ 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, 'mobile' => $user->mobile, ], ]); } /** * 用户登录 * POST /api/user/login */ public function login() { $account = $this->request->post('account'); $password = $this->request->post('password'); if (!$account || !$password) { $this->error('账号和密码不能为空'); } // 支持用户名/邮箱/手机号登录 $user = User::where('username|email|mobile', $account)->find(); if (!$user) { $this->error('账号不存在'); } if (!\app\common\library\Hash::check($password, $user->password)) { $this->error('密码错误'); } if ($user->status != 'normal') { $this->error('账号已被禁用'); } // 更新登录信息 $user->save([ 'logintime' => time(), 'loginip' => $this->request->ip(), ]); // 生成 Token $token = Token::create($user->id, 'user', 86400 * 7); $this->success('登录成功', [ 'token' => $token, 'user' => [ 'id' => $user->id, 'username' => $user->username, 'avatar' => $user->avatar, 'email' => $user->email, ], ]); } /** * 获取当前用户信息 * GET /api/user/info */ public function info() { $user = $this->auth->getUser(); $this->success('获取成功', [ 'id' => $user->id, 'username' => $user->username, 'avatar' => $user->avatar, 'email' => $user->email, 'mobile' => $user->mobile, 'createtime'=> $user->createtime, ]); } /** * 退出登录 * POST /api/user/logout */ public function logout() { Token::clear($this->token); $this->success('退出成功'); } }3.2 Token 机制FastAdmin 的 Token 存储在 fa_token 表中:// 生成 Token(有效期 7 天) $token = Token::create($userId, 'user', 86400 * 7); // 验证 Token $userId = Token::get($token); // 刷新 Token $token = Token::refresh($token); // 清除 Token Token::clear($token);前端请求时在 Header 中携带 Token:// axios 请求拦截器 axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['token'] = token; } return config; }); // 响应拦截器处理 401 axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );四、RESTful 接口开发4.1 文章接口完整实现<?php // application/api/controller/Article.php namespace app\api\controller; use app\common\controller\Api; use app\common\model\Article as ArticleModel; use think\Validate; class Article extends Api { protected $noNeedLogin = ['index', 'detail']; protected $noNeedRight = ['*']; /** * 文章列表 * GET /api/article/index?page=1&limit=10&keyword=xxx */ public function index() { $page = $this->request->get('page/d', 1); $limit = $this->request->get('limit/d', 10); $keyword = $this->request->get('keyword', ''); $categoryId = $this->request->get('category_id/d', 0); $query = ArticleModel::where('status', 'normal'); // 关键词搜索 if ($keyword) { $query->where('title', 'like', "%{$keyword}%"); } // 分类筛选 if ($categoryId) { $query->where('category_id', $categoryId); } // 分页查询 $total = $query->count(); $list = $query->order('id', 'desc') ->field('id,title,author,views,createtime') ->page($page, $limit) ->select(); $this->success('获取成功', [ 'total' => $total, 'page' => $page, 'limit' => $limit, 'list' => $list, ]); } /** * 文章详情 * GET /api/article/detail?id=1 */ public function detail() { $id = $this->request->get('id/d', 0); if (!$id) { $this->error('参数错误'); } $article = ArticleModel::where('id', $id) ->where('status', 'normal') ->find(); if (!$article) { $this->error('文章不存在'); } // 浏览量 +1 ArticleModel::where('id', $id)->inc('views')->update(); $this->success('获取成功', $article); } /** * 创建文章 * POST /api/article/add */ public function add() { $data = $this->request->post(); $data['admin_id'] = $this->auth->id; $validate = new Validate([ 'title' => 'require|length:2,100', 'content' => 'require|min:10', ], [ 'title.require' => '标题不能为空', 'content.require' => '内容不能为空', ]); if (!$validate->check($data)) { $this->error($validate->getError()); } $data['createtime'] = time(); $data['updatetime'] = time(); $article = new ArticleModel(); $article->allowField(true)->save($data); $this->success('创建成功', ['id' => $article->id]); } /** * 编辑文章 * PUT /api/article/edit?id=1 */ public function edit() { $id = $this->request->get('id/d', 0); $data = $this->request->post(); $article = ArticleModel::find($id); if (!$article) { $this->error('文章不存在'); } // 权限检查:只能编辑自己的文章 if ($article->admin_id != $this->auth->id) { $this->error('无权操作', null, 403); } $data['updatetime'] = time(); $article->allowField(true)->save($data); $this->success('修改成功'); } /** * 删除文章 * DELETE /api/article/del?id=1 */ public function del() { $id = $this->request->get('id/d', 0); $article = ArticleModel::find($id); if (!$article) { $this->error('文章不存在'); } if ($article->admin_id != $this->auth->id) { $this->error('无权操作', null, 403); } $article->delete(); $this->success('删除成功'); } }4.2 路由配置在 application/api/route.php 中定义路由规则:<?php use think\Route; // 文章相关 Route::get('articles', 'api/article/index'); Route::get('article/:id', 'api/article/detail'); Route::post('article', 'api/article/add'); Route::put('article/:id', 'api/article/edit'); Route::delete('article/:id', 'api/article/del'); // 用户相关 Route::post('register', 'api/user/register'); Route::post('login', 'api/user/login'); Route::get('user/info', 'api/user/info'); Route::post('logout', 'api/user/logout');五、接口文档自动生成FastAdmin 内置 API 文档生成功能:# 生成 API 文档 php think api --force=true文档输出到 public/api.html,包含所有接口的参数说明、返回示例和在线测试功能。注释规范/** * @ApiTitle (文章列表) * @ApiSummary (获取文章列表,支持分页和搜索) * @ApiMethod (GET) * @ApiRoute (/api/article/index) * @ApiParams (name="page", type="integer", required=false, description="页码,默认1") * @ApiParams (name="limit", type="integer", required=false, description="每页条数,默认10") * @ApiParams (name="keyword", type="string", required=false, description="搜索关键词") * @ApiReturn ({"code":1,"msg":"获取成功","data":{"total":100,"list":[...]}}) */ public function index() { // ... }六、与前端对接6.1 Vue3 对接示例// api/article.ts import axios from 'axios' const API_BASE = '/api' export interface Article { id: number title: string author: string views: number createtime: number } export interface ArticleListResponse { total: number page: number limit: number list: Article[] } export const getArticles = (params: { page?: number limit?: number keyword?: string }) => { return axios.get<{ code: number; data: ArticleListResponse }>( `${API_BASE}/article/index`, { params } ) } export const getArticleDetail = (id: number) => { return axios.get<{ code: number; data: Article }>( `${API_BASE}/article/detail`, { params: { id } } ) }6.2 小程序对接示例// utils/request.js const BASE_URL = 'https://api.example.com' export function request(options) { const token = wx.getStorageSync('token') return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data, header: { 'token': token, 'Content-Type': 'application/json' }, success(res) { if (res.data.code === 1) { resolve(res.data.data) } else if (res.data.code === 0 && res.statusCode === 401) { wx.removeStorageSync('token') wx.redirectTo({ url: '/pages/login/login' }) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail(err) { reject(err) } }) }) } // 调用 request({ url: '/api/article/index', data: { page: 1, limit: 10 } }) .then(data => { console.log(data.list) })七、安全防护7.1 接口限流// 使用缓存实现简单限流 public function _initialize() { parent::_initialize(); $ip = $this->request->ip(); $key = 'api_rate_' . $ip; $count = \think\Cache::inc($key); if ($count == 1) { \think\Cache::expire($key, 60); // 60秒内 } if ($count > 60) { $this->error('请求过于频繁', null, 429); } }7.2 签名验证// 客户端生成签名 // sign = md5(参数按key排序拼接 + secret_key) public function verifySign($params, $sign) { unset($params['sign']); ksort($params); $str = http_build_query($params) . '&key=' . config('api_secret'); return md5($str) === $sign; }总结FastAdmin API 模块提供了完整的前后端分离方案:统一返回格式和错误处理Token 认证机制,支持多端登录RESTful 路由设计,接口语义清晰内置 API 文档自动生成配合 Vue3/小程序等前端框架,快速搭建完整应用
2026年03月29日
17 阅读
0 评论
0 点赞
2026-03-28
FastAdmin 插件开发实战:从零开发自定义插件
前言FastAdmin 的插件系统是其生态的核心——通过在线安装插件,可以快速扩展论坛、CMS、商城等功能模块,无需修改框架核心代码。本文将从零开始,带你完整开发一个 FastAdmin 插件。一、插件目录结构一个标准的 FastAdmin 插件目录如下:addons/ └── mydemo/ # 插件目录(插件标识) ├── Mydemo.php # 插件主类 ├── config.php # 插件配置文件 ├── info.ini # 插件信息文件 ├── controller/ │ └── Index.php # 插件控制器 ├── model/ │ └── Demo.php # 插件模型 ├── view/ │ └── index/ │ └── index.html # 插件视图 ├── public/ │ ├── css/ │ │ └── mydemo.css # 插件样式 │ └── js/ │ └── mydemo.js # 插件脚本 └── hooks.php # 钩子定义(可选)二、创建插件2.1 使用命令行创建php think addon -a mydemo -c create这会在 addons/mydemo/ 目录下自动生成插件骨架文件。2.2 info.ini 插件信息name = mydemo title = 示例插件 description = 这是一个 FastAdmin 示例插件 author = Your Name version = 1.0.0 state = 1字段说明name插件标识(目录名,全小写)title插件名称description插件描述author作者version版本号state状态:1=正常,0=禁用2.3 插件主类 Mydemo.php<?php namespace addons\mydemo; use think\Addons; class Mydemo extends Addons { // 插件安装时执行 public function install() { // 创建数据表 $sql = "CREATE TABLE IF NOT EXISTS `fa_mydemo` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL DEFAULT '', `content` text, `createtime` int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; \think\Db::execute($sql); return true; } // 插件卸载时执行 public function uninstall() { // 删除数据表 \think\Db::execute("DROP TABLE IF EXISTS `fa_mydemo`"); return true; } // 插件启用时执行 public function enable() { return true; } // 插件禁用时执行 public function disable() { return true; } // 钩子:在后台首页显示 public function adminIndexTop($params) { echo '<div class="alert alert-info">欢迎使用 Mydemo 插件!</div>'; } }2.4 config.php 插件配置<?php return [ [ 'name' => 'title', 'title' => '插件标题', 'type' => 'text', 'content' => '', 'value' => 'Mydemo Plugin', 'rule' => 'required', 'msg' => '请填写插件标题', 'tip' => '显示在插件管理页面的标题', ], [ 'name' => 'display_count', 'title' => '显示数量', 'type' => 'number', 'content' => '', 'value' => '10', 'rule' => 'required', 'msg' => '请填写显示数量', 'tip' => '前台显示的数据条数', ], [ 'name' => 'is_open', 'title' => '是否开启', 'type' => 'radio', 'content' => ['0' => '关闭', '1' => '开启'], 'value' => '1', 'rule' => 'required', 'msg' => '', 'tip' => '', ], ];配置在后台 → 插件管理 → 配置 中可编辑,读取方式:$config = get_addon_config('mydemo'); $title = $config['title'];三、插件控制器3.1 后台控制器<?php // addons/mydemo/controller/Index.php namespace addons\mydemo\controller; use app\common\controller\Backend; use think\Db; class Index extends Backend { // 不需要登录的方法 protected $noNeedLogin = []; // 不需要权限验证的方法 protected $noNeedRight = []; /** * 插件后台首页 */ public function index() { $list = Db::name('mydemo')->order('id', 'desc')->paginate(15); $this->view->assign('list', $list); return $this->view->fetch(); } /** * 添加数据 */ public function add() { if ($this->request->isPost()) { $data = $this->request->post('row/a'); $data['createtime'] = time(); Db::name('mydemo')->insert($data); $this->success('添加成功'); } return $this->view->fetch(); } /** * 删除数据 */ public function del($ids = null) { Db::name('mydemo')->where('id', 'in', $ids)->delete(); $this->success('删除成功'); } }3.2 访问路由插件控制器的 URL 格式为:http://yourdomain.com/addons/mydemo/index/index对应:addons - 固定前缀mydemo - 插件标识index - 控制器名index - 方法名3.3 前台控制器<?php // addons/mydemo/controller/Api.php namespace addons\mydemo\controller; use app\common\controller\Api; class Api extends Api { // 不需要登录的方法 protected $noNeedLogin = ['getList']; /** * 获取数据列表(API 接口) */ public function getList() { $count = \think\Db::name('mydemo')->count(); $list = \think\Db::name('mydemo') ->order('id', 'desc') ->limit(10) ->select(); $this->success('获取成功', [ 'total' => $count, 'list' => $list, ]); } }四、钩子机制(Hook)4.1 注册钩子在插件主类中定义钩子方法,方法名即钩子名:// addons/mydemo/Mydemo.php class Mydemo extends Addons { // 钩子:文章详情页底部 public function articleDetailBottom($params) { // $params 是传递的参数 $article = $params['article']; // 输出自定义内容 echo '<div class="mydemo-box">'; echo '<h3>相关推荐</h3>'; echo '</div>'; } // 钩子:后台导航栏 public function adminSidebar($params) { echo '<li><a href="' . addon_url('mydemo/index/index') . '">'; echo '<i class="fa fa-puzzle-piece"></i> <span>Mydemo</span>'; echo '</a></li>'; } }4.2 触发钩子在任意位置触发钩子:// 传递参数给钩子 \think\Hook::listen('article_detail_bottom', ['article' => $article]);4.3 FastAdmin 内置常用钩子钩子名触发位置参数admin_index_top后台首页顶部无admin_sidebar后台左侧菜单无article_detail_bottom文章详情底部article 数据user_register_after用户注册后user 数据config_init配置初始化配置数组五、插件打包发布5.1 打包# 将插件目录打包为 zip cd addons zip -r mydemo.zip mydemo/注意:打包时不要包含 runtime、temp 等临时目录。5.2 发布到插件市场注册 FastAdmin 官网账号进入"开发者中心"点击"发布插件"上传 zip 包,填写插件信息等待审核通过5.3 版本更新更新 info.ini 中的版本号后重新打包上传,用户可在后台一键升级。六、调试技巧6.1 开启插件调试// application/config.php 'app_debug' => true; // 确保插件目录有读写权限 chmod -R 755 addons/mydemo/6.2 查看插件日志// 在插件代码中记录日志 \think\Log::info('Mydemo: ' . json_encode($data)); // 查看日志 // runtime/log/202607/24.log6.3 热更新开发阶段修改插件代码后,清除缓存即可生效:php think clear --type=cache php think clear --type=temp七、完整插件开发流程1. php think addon -a mydemo -c create → 创建骨架 2. 编辑 info.ini → 填写插件信息 3. 编辑 Mydemo.php → 实现安装/卸载/钩子 4. 编辑 config.php → 定义插件配置项 5. 编写 controller/ → 实现业务逻辑 6. 编写 view/ → 编写视图模板 7. 编写 public/ → 添加前端资源 8. 后台安装插件 → 测试 → 调试 9. 打包 zip → 发布总结FastAdmin 插件系统通过目录约定 + 钩子机制实现了松耦合的功能扩展。核心要点:插件主类继承 think\Addons,实现 install/uninstall 生命周期通过 config.php 定义可视化配置项通过 Hook::listen() 在框架任意位置触发钩子插件可以有自己的 MVC 结构,独立于主应用
2026年03月28日
36 阅读
0 评论
0 点赞
2026-03-26
FastAdmin 权限认证体系:RBAC 角色与菜单管理
前言FastAdmin 内置了完整的 RBAC(基于角色的访问控制)权限体系,支持细粒度的菜单管理、操作权限和数据权限控制。本文将深入讲解 FastAdmin 权限认证的工作原理和实际配置方法。一、RBAC 权限模型概述FastAdmin 的权限体系包含三个核心实体:管理员(Admin)──→ 角色组(Group)──→ 权限规则(Rule)实体数据表说明管理员fa_admin后台用户,每个用户属于一个或多个角色组角色组fa_group角色定义,每个角色关联一组权限规则权限规则fa_auth_rule权限节点,对应控制器的每个操作方法二、权限认证流程2.1 认证类 AuthFastAdmin 的权限认证由 application/common/library/Auth.php 实现:namespace app\common\library; use think\Session; use think\Cookie; use app\admin\model\Admin as AdminModel; class Auth { protected $logined = false; // 是否登录 protected $admin = null; // 当前管理员信息 protected $error = ''; // 错误信息 protected $requestUri = ''; // 当前请求 URL // 检查是否已登录 public function isLogin() { if ($this->logined) { return true; } $admin = Session::get('admin'); if (!$admin) { return false; } // 验证管理员状态 $this->admin = AdminModel::get($admin['id']); if (!$this->admin || $this->admin['status'] != 'normal') { return false; } $this->logined = true; return true; } // 检查权限 public function check($name = '') { if (!$this->isLogin()) { return false; } // 超级管理员直接通过 if ($this->admin['id'] == 1) { return true; } // 获取当前用户的所有权限规则 $rules = $this->getRuleList(); if (!$name) { $name = strtolower($this->request->module() . '/' . $this->request->controller() . '/' . $this->request->action()); } $name = strtolower($name); // 检查是否有匹配的权限 foreach ($rules as $rule) { if ($this->checkRule($rule, $name)) { return true; } } return false; } }2.2 权限检查链路请求进入 → Backend 控制器初始化 → Auth::check() 检查当前 URL 是否有权限 → 无权限 → 返回 403 / 跳转登录 → 有权限 → 继续执行2.3 控制器中的权限注解FastAdmin 通过注解方式声明权限节点:// application/admin/controller/content/Article.php namespace app\admin\controller\content; use app\common\controller\Backend; class Article extends Backend { // 默认不需要登录的方法 protected $noNeedLogin = []; // 默认不需要权限验证的方法 protected $noNeedRight = []; /** * 文章列表 */ public function index() { return parent::index(); } /** * 添加文章 */ public function add() { return parent::add(); } /** * 编辑文章 */ public function edit($ids = null) { return parent::edit($ids); } /** * 删除文章 */ public function del($ids = null) { return parent::del($ids); } }每个 public 方法自动注册为一个权限节点,格式为 content/article/index。三、菜单管理3.1 菜单结构菜单存储在 fa_auth_rule 表中,采用树形结构:-- 查看菜单结构 SELECT id, pid, name, title, type FROM fa_auth_rule ORDER BY weigh DESC;字段说明id节点 IDpid父节点 ID(0 为顶级)name权限规则名(URL 路径)title菜单显示名称type类型:menu=菜单,file=操作节点icon菜单图标(FontAwesome)ismenu是否在左侧菜单显示(1=显示)3.2 命令行生成菜单# 根据控制器自动生成菜单 php think menu -c content/article # 强制覆盖 php think menu -c content/article -f 13.3 后台手动管理菜单进入 后台 → 权限管理 → 菜单规则,可以:添加/编辑/删除菜单节点拖拽排序设置菜单图标控制菜单是否显示3.4 菜单与权限的关系菜单规则(fa_auth_rule) ├── 内容管理 (ismenu=1, type=menu) │ ├── 文章管理 (ismenu=1, type=menu) │ │ ├── 查看 (ismenu=0, type=file) → content/article/index │ │ ├── 添加 (ismenu=0, type=file) → content/article/add │ │ ├── 编辑 (ismenu=0, type=file) → content/article/edit │ │ └── 删除 (ismenu=0, type=file) → content/article/delismenu=1 的节点显示在左侧导航栏,ismenu=0 的是操作按钮权限。四、角色组管理4.1 创建角色组进入 后台 → 权限管理 → 角色组:点击"添加"填写角色名称(如"内容编辑")勾选该角色拥有的权限保存4.2 角色权限继承角色组支持树形结构,子角色自动继承父角色的所有权限:超级管理员(id=1,拥有所有权限) ├── 内容主管(拥有内容管理全部权限) │ ├── 内容编辑(拥有文章增改权限,无删除权限) │ └── 内容审核(拥有文章审核权限) └── 系统管理员(拥有系统设置权限)4.3 数据权限FastAdmin 支持基于角色组的数据隔离:// 在控制器中启用数据权限 public function index() { // 只能查看自己创建的数据 $this->dataLimit = 'personal'; // 只能查看本角色组的数据 $this->dataLimit = 'auth'; // 自定义数据限制字段 $this->dataLimitField = 'admin_id'; return parent::index(); }数据权限模式说明:模式说明null不限制,可查看所有数据personal只能查看自己创建的数据auth只能查看本角色组及子角色组的数据五、API Token 认证除了后台 Session 认证,FastAdmin 的 API 模块支持 Token 认证:5.1 Token 生成// application/api/controller/User.php namespace app\api\controller; use app\common\controller\Api; use app\common\library\Token; class User extends Api { // 登录获取 Token public function login() { $username = $this->request->post('username'); $password = $this->request->post('password'); $admin = \app\admin\model\Admin::get(['username' => $username]); if (!$admin || $admin->password != md5(md5($password) . $admin->salt)) { $this->error('用户名或密码错误'); } // 生成 Token $token = Token::create($admin['id'], 'admin', 86400 * 7); $this->success('登录成功', [ 'token' => $token, 'admin' => $admin, ]); } }5.2 Token 验证中间件// API 请求头携带 Token // Authorization: Bearer xxx-xxx-xxx // 在 API 控制器中验证 namespace app\api\controller; class Order extends Api { // 需要登录的方法 protected $noNeedLogin = []; public function list() { // $this->auth 已自动初始化 $adminId = $this->auth->id; $orders = model('Order')->where('admin_id', $adminId)->select(); $this->success('获取成功', $orders); } }六、安全加固建议6.1 密码安全// FastAdmin 使用双重 MD5 加盐 // password = md5(md5(明文密码) . salt) // 自定义更安全的加密方式 \app\admin\model\Admin::event('before_insert', function ($admin) { $admin->salt = \fast\Random::alnum(8); $admin->password = password_hash($admin->password, PASSWORD_BCRYPT); });6.2 登录失败限制FastAdmin 默认 5 次登录失败后锁定 15 分钟,可在配置中调整:// application/extra/site.php 'login_failure_retry' => 5, // 最大失败次数 'login_failure_lock' => 900, // 锁定时间(秒)6.3 操作日志所有后台操作自动记录到 fa_admin_log 表:// 手动记录日志 \app\admin\model\AdminLog::record([ 'title' => '手动操作文章', 'content' => '修改了文章ID:' . $id, ]);总结FastAdmin 的权限体系基于标准 RBAC 模型,通过菜单规则表实现细粒度权限控制。核心要点:权限规则对应控制器的每个方法,自动注册角色组支持树形继承,子角色继承父角色权限数据权限支持 personal 和 auth 两种模式API 模块通过 Token 实现无状态认证
2026年03月26日
7 阅读
0 评论
0 点赞
1
2
3
...
5
0:00