本文最早于发表本人博客: Laravel 自带 Auth 密码重置源码解析及扩展实现手机号密码找回
Larval 自带 Auth 密码重置源码解析及扩展实现手机号密码找回
Larval技术群小伙伴问密码重置时PasswordController中需要设置的$broker
是干嘛用的,正好来写一下Laravel 中Auth的ResetsPasswords
,以及实践一下扩展,所以大体这篇博客写写:
- 密码重置源码分析
- 实现自定义邮件发送方式进行密码重置,比如使用第三方或者自己发送邮件方式找回
- 实现手机号密码重置
首先来看一下PasswordController 中的 ResetsPasswords trait
trait ResetsPasswords
{
use RedirectsUsers;
public function getEmail()
{
return $this->showLinkRequestForm();
}
/**
* 这里就是设置密码重置邮件内容的
*
* @return \Illuminate\Http\Response
*/
public function showLinkRequestForm()
{
//所以我们可以在PoasswrodController 中设置 protected $linkRequestView 来定义密码重置邮件模板
if (property_exists($this, 'linkRequestView')) {
return view($this->linkRequestView);
}
if (view()->exists('auth.passwords.email')) {
return view('auth.passwords.email');
}
return view('auth.password');
}
/**
* 发送密码重置邮件
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function postEmail(Request $request)
{
return $this->sendResetLinkEmail($request);
}
/**
* 给重置密码的用户发送邮件
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
$broker = $this->getBroker(); //获取broker,下面会讲
$response = Password::broker($broker)->sendResetLink($request->only('email'), function (Message $message) {
$message->subject($this->getEmailSubject());
}); //根据 broker 来发送密码重置邮件,下面会详细讲
switch ($response) {
case Password::RESET_LINK_SENT: //状态,下面会讲
return $this->getSendResetLinkEmailSuccessResponse($response);
case Password::INVALID_USER:
default:
return $this->getSendResetLinkEmailFailureResponse($response);
}
}
/**
* 邮件标题
*
* @return string
*/
protected function getEmailSubject()
{
return property_exists($this, 'subject') ? $this->subject : 'Your Password Reset Link';
}
/**
* 邮件成功发送过以后返回
*
* @param string $response
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function getSendResetLinkEmailSuccessResponse($response)
{
return redirect()->back()->with('status', trans($response));
}
/**
* 邮件发送时候返回
*
* @param string $response
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function getSendResetLinkEmailFailureResponse($response)
{
return redirect()->back()->withErrors(['email' => trans($response)]);
}
/**
* 用户点击邮箱里面重置连接后跳转的页面,就是重置密码页面
* @param \Illuminate\Http\Request $request
* @param string|null $token
* @return \Illuminate\Http\Response
*/
public function getReset(Request $request, $token = null)
{
return $this->showResetForm($request, $token);
}
/**
* 用户点击邮箱里面重置连接后跳转的页面,就是重置密码页面
*
* @param \Illuminate\Http\Request $request
* @param string|null $token
* @return \Illuminate\Http\Response
*/
public function showResetForm(Request $request, $token = null)
{
if (is_null($token)) {
return $this->getEmail();
}
$email = $request->input('email');
//所以我们可以在PoasswrodController 中设置 protected $resetView 来定义密码重置的页面
if (property_exists($this, 'resetView')) {
return view($this->resetView)->with(compact('token', 'email'));
}
if (view()->exists('auth.passwords.reset')) {
return view('auth.passwords.reset')->with(compact('token', 'email'));
}
return view('auth.reset')->with(compact('token', 'email'));
}
/**
* 重置密码
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function postReset(Request $request)
{
return $this->reset($request);
}
/**
* 重置密码实现
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function reset(Request $request)
{
$this->validate($request, [
'token' => 'required',
'email' => 'required|email',
'password' => 'required|confirmed|min:6',
]);
$credentials = $request->only(
'email', 'password', 'password_confirmation', 'token'
);
$broker = $this->getBroker();
$response = Password::broker($broker)->reset($credentials, function ($user, $password) { //注意这个回调
$this->resetPassword($user, $password);
}); //根据 broker重置密码,下面会详细讲
switch ($response) {
case Password::PASSWORD_RESET:
return $this->getResetSuccessResponse($response);
default:
return $this->getResetFailureResponse($request, $response);
}
}
/**
* 重置密码,并且重新登陆
*
* @param \Illuminate\Contracts\Auth\CanResetPassword $user
* @param string $password
* @return void
*/
protected function resetPassword($user, $password)
{
$user->password = bcrypt($password);
$user->save();
Auth::guard($this->getGuard())->login($user);
}
//下面的代码略
}
上面其实就是路由的实现方法,主要路由如下:
Method | URI | Action |
---|---|---|
POST | password/email | AppHttpControllersAuthPasswordController@sendResetLinkEmail |
POST | password/reset | AppHttpControllersAuthPasswordController@reset |
GET | HEAD | password/reset/{token?} |
首先来主要看下sendResetLinkEmail
方法,这个方法主要实现根据用户填入的邮箱地址来发送重置邮件的
$response = Password::broker($broker)->sendResetLink($request->only('email'), function (Message $message) {
$message->subject($this->getEmailSubject());
}); //根据 broker 来发送密码重置邮件,下面会详细讲
switch ($response) {
case Password::RESET_LINK_SENT: //状态,下面会讲
return $this->getSendResetLinkEmailSuccessResponse($response);
case Password::INVALID_USER:
default:
return $this->getSendResetLinkEmailFailureResponse($response);
}
上面的Password
就是Facade,我们看一下这个Facade:
Illuminate/Support/Facades/Password.php
<?php
namespace Illuminate\Support\Facades;
/**
* @see \Illuminate\Auth\Passwords\PasswordBroker
*/
class Password extends Facade
{
/**
* Constant representing a successfully sent reminder.
*
* @var string
*/
const RESET_LINK_SENT = 'passwords.sent';
/**
* Constant representing a successfully reset password.
*
* @var string
*/
const PASSWORD_RESET = 'passwords.reset';
/**
* Constant representing the user not found response.
*
* @var string
*/
const INVALID_USER = 'passwords.user';
/**
* Constant representing an invalid password.
*
* @var string
*/
const INVALID_PASSWORD = 'passwords.password';
/**
* Constant representing an invalid token.
*
* @var string
*/
const INVALID_TOKEN = 'passwords.token';
protected static function getFacadeAccessor()
{
return 'auth.password';
}
}
可以看到上面邮件发送后等状态的判断也是在这个Facade中定义的,那么auth.password
的这个是绑定到哪个类中实现的?继续查看对应的ServiceProvider的register
Illuminate/Auth/Passwords/PasswordResetServiceProvider.php
<?php
namespace Illuminate\Auth\Passwords;
use Illuminate\Support\ServiceProvider;
class PasswordResetServiceProvider extends ServiceProvider
{
protected $defer = true;
public function register()
{
$this->registerPasswordBroker();
}
protected function registerPasswordBroker()
{
$this->app->singleton('auth.password', function ($app) {
return new PasswordBrokerManager($app);
});
$this->app->bind('auth.password.broker', function ($app) {
return $app->make('auth.password')->broker();
});
}
public function provides()
{
return ['auth.password', 'auth.password.broker'];
}
}
看到了PasswordBrokerManager($app);
,那么我们就知道了上面Passwrod::broker
的实现在PasswordBrokerManager
中,那我们先来看下是如何发送这个重置密码邮件的
Illuminate/Auth/Passwords/PasswordBrokerManager.php
<?php
namespace Illuminate\Auth\Passwords;
use InvalidArgumentException;
use Illuminate\Contracts\Auth\PasswordBrokerFactory as FactoryContract;
class PasswordBrokerManager implements FactoryContract
{
// 略
/**
* 获取broker
*
* @param string $name
* @return \Illuminate\Contracts\Auth\PasswordBroker
*/
public function broker($name = null)
{
$name = $name ?: $this->getDefaultDriver();
return isset($this->brokers[$name])
? $this->brokers[$name]
: $this->brokers[$name] = $this->resolve($name);
}
/**
* Resolve the given broker.
*
* @param string $name
* @return \Illuminate\Contracts\Auth\PasswordBroker
*
* @throws \InvalidArgumentException
*/
protected function resolve($name)
{
$config = $this->getConfig($name); //获取auth.php配置中的passwords broker
if (is_null($config)) {
throw new InvalidArgumentException("Password resetter [{$name}] is not defined.");
}
//这里很重要,就是实例一个PasswordBroker
return new PasswordBroker(
$this->createTokenRepository($config),
$this->app['auth']->createUserProvider($config['provider']),
$this->app['mailer'],
$config['email']
);
}
/**
* 根据配置创建一个token实例
*
* @param array $config
* @return \Illuminate\Auth\Passwords\TokenRepositoryInterface
*/
protected function createTokenRepository(array $config)
{
return new DatabaseTokenRepository(
$this->app['db']->connection(),
$config['table'],
$this->app['config']['app.key'],
$config['expire']
);
}
//下面略
public function __call($method, $parameters)
{
return call_user_func_array([$this->broker(), $method], $parameters);
}
}
上面的resolve
返回了new PasswordBroker
,这里的PasswordBroker
其实才是密码重置的核心实现,里面主要做了实现一下几件事情:
- 创建邮件验证的token,并发送重置密码邮件
- 用户点击重置连接以后根据token进行验证
- 重置旧的密码成用户提交的新密码
Illuminate/Auth/Passwords/PasswordBroker.php
<?php
namespace Illuminate\Auth\Passwords;
use Closure;
use Illuminate\Support\Arr;
use UnexpectedValueException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Mail\Mailer as MailerContract;
use Illuminate\Contracts\Auth\PasswordBroker as PasswordBrokerContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
class PasswordBroker implements PasswordBrokerContract
{
//上面的略
public function __construct(TokenRepositoryInterface $tokens,
UserProvider $users,
MailerContract $mailer,
$emailView)
{
$this->users = $users;
$this->mailer = $mailer;
$this->tokens = $tokens;
$this->emailView = $emailView;
}
/**
* 给用户发送包含重置链接的邮件
*
* @param array $credentials
* @param \Closure|null $callback
* @return string
*/
public function sendResetLink(array $credentials, Closure $callback = null)
{
// 验证用户
$user = $this->getUser($credentials);
if (is_null($user)) {
return PasswordBrokerContract::INVALID_USER;
}
// 生成token
$token = $this->tokens->create($user);
//发送邮件
$this->emailResetLink($user, $token, $callback);
return PasswordBrokerContract::RESET_LINK_SENT;
}
/**
* 发送邮件的实现
*
* @param \Illuminate\Contracts\Auth\CanResetPassword $user
* @param string $token
* @param \Closure|null $callback
* @return int
*/
public function emailResetLink(CanResetPasswordContract $user, $token, Closure $callback = null)
{
//把token和user变量传递到邮件模板中,并发送邮件
$view = $this->emailView;
return $this->mailer->send($view, compact('token', 'user'), function ($m) use ($user, $token, $callback) {
$m->to($user->getEmailForPasswordReset());
if (! is_null($callback)) {
call_user_func($callback, $m, $user, $token);
}
});
}
/**
* 根据token重置密码
*
* @param array $credentials
* @param \Closure $callback
* @return mixed
*/
public function reset(array $credentials, Closure $callback)
{
//实现根据$credentials来验证用户是否可以更改更改密码
$user = $this->validateReset($credentials);
if (! $user instanceof CanResetPasswordContract) {
return $user;
}
$pass = $credentials['password'];
// 下面这个就是产生新的密码的实现
call_user_func($callback, $user, $pass);
$this->tokens->delete($credentials['token']);
return PasswordBrokerContract::PASSWORD_RESET;
}
//下面的是一些验证的方法,略
}
上面的reset
中的call_user_func
就是调用了重置新密码的逻辑,$callback
其实就是最上面的trait ResetsPasswords
中的resetPassword($user, $password)
来保存新密码。
到这里Laravel 自带Auth的密码重置的源码解读部分就完成了,下面我们就通过扩展一下实现手机号密码找回和自定义邮件发送方式找回密码,根据上面的代码解析如果你看懂的话应该了解,其实我们只要扩展PasswordBroker.php
和PasswordBrokerManager.php
就可以了。
自定义邮件发送和手机号发送验证码逻辑类请自行实现,以下代码的EmailService和SmsService分别表示发送邮件和发送短信的类,自己按照需求进行封装,比如SendCloud发送邮件,云通讯发送手机短信验证码的具体实现
自定义邮件重置密码的逻辑基本都一样的,不变,手机号重置密码的过程应该是这样的:
- 用户填入手机号,点击“发送验证码”按钮,收到验证码
- 将验证码填入,点击“密码找回”
- 后台进行验证码校验,没有问题跳转到新密码设置页面
- 新密码设置
路由如下:
Route::post('password/email', 'Auth\PasswordController@sendResetLinkEmail');
//通过邮件重置密码
Route::post('password/reset-mail', 'Auth\PasswordController@resetBymail');
//发送手机短信验证码
Route::post('password/phone', 'Auth\PasswordController@sendResetCodePhone');
//通过手机验证码找回密码
Route::post('password/reset-phone', 'Auth\PasswordController@resetByPhone');
在app目录下建立入如下目录和文件(根据个人习惯):
Foundation/
├── Auth
├── Passwords
├── RyanPasswordBroker.php
├── RyanPasswordBrokerManager.php
└── Facade
└── RyanPassword.php
新建ServiceProvider,将auth.password绑定到我们自己的RyanPasswordBroker
app/Providers/RyanPasswordResetServiceProvider.php
<?php
namespace App\Providers;
use App\Foundation\Auth\Passwords\RyanPasswordBrokerManager;
use Illuminate\Support\ServiceProvider;
class RyanPasswordResetServiceProvider extends ServiceProvider
{
protected $defer = true;
public function register()
{
$this->registerPasswordBroker();
}
protected function registerPasswordBroker()
{
$this->app->singleton('auth.password', function ($app) {
return new RyanPasswordBrokerManager($app);
});
$this->app->bind('auth.password.broker', function ($app) {
return $app->make('auth.password')->broker();
});
}
public function provides()
{
return ['auth.password', 'auth.password.broker'];
}
}
修改config/app.php
'providers' => [
......
App\Providers\RyanPasswordResetServiceProvider::class,
],
'aliases' => [
......
'RyanPassword' => App\Foundation\Auth\Passwords\Facade\RyanPassword::class,
],
app/Foundation/Auth/Passwords/Facade/RyanPassword.php
<?php
namespace App\Foundation\Auth\Passwords\Facade;
use Illuminate\Support\Facades\Password;
/**
* @see \Illuminate\Auth\Passwords\PasswordBroker
*/
class RyanPassword extends Password
{
}
app/Foundation/Auth/Passwords/RyanPasswordBroker.php
<?php
namespace App\Foundation\Auth\Passwords;
use App\Services\SmsService;
use Closure;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Arr;
use UnexpectedValueException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Mail\Mailer as MailerContract;
use Illuminate\Contracts\Auth\PasswordBroker as PasswordBrokerContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Auth\Passwords\TokenRepositoryInterface;
use App\Services\EmailService;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Support\Facades\Redis;
class RyanPasswordBroker extends PasswordBroker
{
//这里注意下,EmailService是自定义发送邮件的方式,自己实现
public function __construct(TokenRepositoryInterface $tokens, UserProvider $users, EmailService $mailer, $emailView)
{
$this->users = $users;
$this->mailer = $mailer;
$this->tokens = $tokens;
$this->emailView = $emailView;
}
public function emailResetLink(CanResetPasswordContract $user, $token, Closure $callback = null)
{
$body = app('view')->make($this->emailView, compact('token', 'user'))->render();
return $this->mailer->send($user->getEmailForPasswordReset(), 'xxx账号密码重置', $body, $fromName = 'xxxx');
}
protected function validateReset(array $credentials)
{
if (is_null($user = $this->getUser($credentials))) {
return PasswordBrokerContract::INVALID_USER;
}
if (!$this->validateNewPassword($credentials)) {
return PasswordBrokerContract::INVALID_PASSWORD;
}
if (isset($credentials['verify_code'])) {
//如果提交的字段含有verify_code表示是手机验证码方式重置密码,需要验证用户提交的验证码是不是刚才发送给他手机号的,验证码发送以后可以保持在缓存中
if (Redis::get('password:telephone:' . $credentials['telephone']) != $credentials['verify_code']) {
return PasswordBrokerContract::INVALID_TOKEN;
}
} elseif (!$this->tokens->exists($user, $credentials['token'])) {
//邮件重置方式
return PasswordBrokerContract::INVALID_TOKEN;
}
return $user;
}
/**
* Get the user for the given credentials.
*
* @param array $credentials
* @return \Illuminate\Contracts\Auth\CanResetPassword
*
* @throws \UnexpectedValueException
*/
public function getUser(array $credentials)
{
$credentials = Arr::except($credentials, ['token', 'verify_code']);//这里注意,如果是手机验证码方式找回密码需要吧verify_code字段排除,以免users表中没有verify_code字段查不到用户
$user = $this->users->retrieveByCredentials($credentials);
if ($user && !$user instanceof CanResetPasswordContract) {
throw new UnexpectedValueException('User must implement CanResetPassword interface.');
}
return $user;
}
/**
* 发送重置密码手机验证码
*
* @param array $credentials
* @param \Closure|null $callback
* @return string
*/
public function sendResetCode(array $credentials, Closure $callback = null)
{
$user = $this->getUser($credentials);
if (is_null($user)) {
return PasswordBrokerContract::INVALID_USER;
}
//我是将手机验证码发送后保持在Redis中,验证的时候也是去redis取
$telephone = $credentials['telephone'];
$code = random_int(100000, 999999);
$result = with(new SmsService())->sendTemplateSms($telephone, config('sms.template_ids.password_verify_code'), [$code]);
$result = json_decode($result, true);
if ($result['status']) {
Redis::setEx('password:telephone:' . $telephone, 3000, $code);
return PasswordBrokerContract::RESET_LINK_SENT;
}
}
/**
* 通过手机验证码重置密码
* @param array $credentials
* @param Closure $callback
* @return CanResetPasswordContract|string
*/
public function resetByPhone(array $credentials, Closure $callback)
{
$user = $this->validateReset($credentials);
if (!$user instanceof CanResetPasswordContract) {
return $user;
}
$pass = $credentials['password'];
call_user_func($callback, $user, $pass);
//如果是手机号重置密码的话新密码保存后需要删除缓存的验证码
Redis::del('password:telephone:' . $credentials['telephone']);
return PasswordBrokerContract::PASSWORD_RESET;
}
}
app/Foundation/Auth/Passwords/RyanPasswordBrokerManager.php
<?php
namespace App\Foundation\Auth\Passwords;
use App\Services\EmailService;
use InvalidArgumentException;
use Illuminate\Contracts\Auth\PasswordBrokerFactory as FactoryContract;
use Illuminate\Auth\Passwords\PasswordBrokerManager as PasswordBrokerManager;
use Illuminate\Auth\Passwords\DatabaseTokenRepository;
class RyanPasswordBrokerManager extends PasswordBrokerManager
{
/**
* The application instance.
*
* @var \Illuminate\Foundation\Application
*/
protected $app;
/**
* The array of created "drivers".
*
* @var array
*/
protected $brokers = [];
protected $mailer;
/**
* Create a new PasswordBroker manager instance.
*
* @param \Illuminate\Foundation\Application $app
* @return void
*/
public function __construct($app)
{
parent::__construct($app);
$this->mailer = new EmailService();
}
/**
* Attempt to get the broker from the local cache.
*
* @param string $name
* @return \Illuminate\Contracts\Auth\PasswordBroker
*/
public function broker($name = null)
{
$name = $name ?: $this->getDefaultDriver();
return isset($this->brokers[$name]) ? $this->brokers[$name] : $this->brokers[$name] = $this->resolve($name);
}
/**
* Resolve the given broker.
*
* @param string $name
* @return \Illuminate\Contracts\Auth\PasswordBroker
*
* @throws \InvalidArgumentException
*/
protected function resolve($name)
{
$config = $this->getConfig($name);
if(is_null($config)) {
throw new InvalidArgumentException("Password resetter [{$name}] is not defined.");
}
//这里实例化我们自定义的RyanPasswordBroker来完成密码重置逻辑
return new RyanPasswordBroker($this->createTokenRepository($config), $this->app['auth']->createUserProvider($config['provider']), $this->mailer, $config['email']);
}
}
修改PasswordController.php
<?php
namespace App\Controllers\Auth;
use App\Controllers\Controller;
use App\Requests\Auth\EmailResetPasswordRequest;
use App\Requests\Auth\EmailResetPasswordSendRequest;
use App\Requests\Auth\PhoneResetPasswordRequest;
use App\Requests\Auth\PhoneResetPasswordSendRequest;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Illuminate\Foundation\Auth\ResetsPasswords;
use RyanPassword, Auth;
class PasswordController extends Controller
{
use ResetsPasswords;
protected $broker = 'users';
public function __construct()
{
$this->middleware('guest');
}
/**
* 发送重置密码邮件
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function sendResetLinkEmail(EmailResetPasswordSendRequest $request)
{
$broker = $this->getBroker();
$response = RyanPassword::broker($broker)->sendResetLink($request->only('email'));
switch ($response) {
case RyanPassword::RESET_LINK_SENT:
return ['status_code' => '200', 'message' => '密码重置邮件已发送'];
case RyanPassword::INVALID_USER:
default:
throw new \UnauthorizedHttpException(401, '该邮箱未注册');
}
}
/**
* 通过邮件重置密码
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function resetBymail(EmailResetPasswordRequest $request)
{
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$broker = $this->getBroker();
$response = RyanPassword::broker($broker)->reset($credentials, function ($user, $password) {
$this->resetPassword($user, $password);
});
switch ($response) {
case RyanPassword::PASSWORD_RESET:
unset($credentials['token']);
unset($credentials['password_confirmation']);
return [
'status_code' => '200',
'message' => '密码重置成功'
];
case RyanPassword::INVALID_TOKEN:
//返回'Token 已经失效'
default:
//返回'密码重置失败'
}
}
/**
* 发送重置密码短信验证码
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function sendResetCodePhone(PhoneResetPasswordSendRequest $request)
{
$broker = $this->getBroker();
$response = RyanPassword::broker($broker)->sendResetCode($request->only('telephone'));
switch ($response) {
case RyanPassword::RESET_LINK_SENT:
return ['status_code' => '200', 'message' => '密码重置验证码已发送'];
case RyanPassword::INVALID_USER:
default:
//返回'该手机号未注册'
}
}
/**
* 通过短信验证码重置密码
* @param PhoneResetPasswordRequest $request
* @return array
*/
public function resetByPhone(PhoneResetPasswordRequest $request)
{
$credentials = $request->only('telephone', 'password', 'password_confirmation', 'verify_code');
$broker = $this->getBroker();
$response = RyanPassword::broker($broker)->resetByPhone($credentials, function ($user, $password) {
$this->resetPassword($user, $password);
});
switch ($response) {
case RyanPassword::PASSWORD_RESET:
unset($credentials['verify_code']);
unset($credentials['password_confirmation']);
return [
'status_code' => '200',
'message' => '密码重置成功',
];
case RyanPassword::INVALID_TOKEN:
//返回'手机验证码已失效'
default:
//返回'密码重置失败'
}
}
}
结束!!
转载请注明: 转载自Ryan是菜鸟 | LNMP技术栈笔记
如果觉得本篇文章对您十分有益,何不 打赏一下
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。