安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
486 lines
17 KiB
PHP
486 lines
17 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\controller\v1;
|
||
|
||
use think\exception\ValidateException;
|
||
use ywxapp\controller\ApiBase;
|
||
use ywxapp\service\JwtService;
|
||
use ywxapp\library\Sms as SmsLib;
|
||
use ywxapp\library\Email as EmailLib;
|
||
use ywxapp\model\MemberUser as UserModel;
|
||
|
||
/**
|
||
* 会员账户接口(标准基础能力,为聊天 / 商城等应用铺路)
|
||
*
|
||
* 路由前缀:/api/v1/user/<action>
|
||
* 鉴权:除 $noNeedLogin 声明的公开接口外,其余接口均需 Bearer Authorization 登录态(由父类统一拦截)。
|
||
*
|
||
* @package app\api\controller\v1
|
||
*/
|
||
class User extends ApiBase
|
||
{
|
||
/**
|
||
* 免登录(公开)接口白名单。
|
||
*
|
||
* @var array<int, string>
|
||
*/
|
||
protected $noNeedLogin = [
|
||
'register',
|
||
'login',
|
||
'loginBySms',
|
||
'refresh',
|
||
'resetPwdBySms',
|
||
'resetPwdByEmail',
|
||
];
|
||
|
||
/**
|
||
* 账号密码注册。
|
||
*
|
||
* 普通注册仅需 account + password;若传入 mobile 则需先通过短信验证码校验。
|
||
* 成功后自动签发 JWT(access_token / refresh_token 由响应自动携带)。
|
||
*
|
||
* @param string $account 登录账号(必填,唯一)
|
||
* @param string $password 登录密码(必填,至少 6 位)
|
||
* @param string $mobile 手机号(可选,注册时必需短信验证码)
|
||
* @param string $captcha 短信验证码(mobile 传入时必填)
|
||
* @param string $nickname 昵称(可选,默认同 account)
|
||
*
|
||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||
*
|
||
* @route POST /api/v1/user/register
|
||
*/
|
||
public function register()
|
||
{
|
||
$account = trim((string) $this->request->post('account', ''));
|
||
$password = (string) $this->request->post('password', '');
|
||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
$nickname = trim((string) $this->request->post('nickname', ''));
|
||
|
||
if ($account === '' || $password === '') {
|
||
return $this->apiError('账号和密码不能为空');
|
||
}
|
||
if (strlen($password) < 6) {
|
||
return $this->apiError('密码至少 6 位');
|
||
}
|
||
if (UserModel::getByAccount($account)) {
|
||
return $this->apiError('该账号已被注册');
|
||
}
|
||
|
||
// 手机号注册需校验短信验证码
|
||
if ($mobile !== '') {
|
||
if ($captcha === '' || ! SmsLib::check($mobile, $captcha, 'register')) {
|
||
return $this->apiError('短信验证码不正确');
|
||
}
|
||
if (UserModel::getByMobile($mobile)) {
|
||
return $this->apiError('该手机号已被注册');
|
||
}
|
||
}
|
||
|
||
$user = new UserModel();
|
||
$user->account = $account;
|
||
$user->password = $password; // 触发 setPasswordAttr 自动 bcrypt
|
||
$user->nickname = $nickname ?: $account;
|
||
if ($mobile !== '') {
|
||
$user->mobile = $mobile;
|
||
}
|
||
$user->status = 1;
|
||
$user->save();
|
||
|
||
return $this->issueToken($user, '注册成功');
|
||
}
|
||
|
||
/**
|
||
* 账号密码登录。
|
||
*
|
||
* 校验账号存在、状态正常、未被锁定,并通过 checkPassword 验证密码;
|
||
* 成功后记录登录信息并签发 JWT(access_token / refresh_token 由响应自动携带)。
|
||
*
|
||
* @param string $account 登录账号(必填)
|
||
* @param string $password 登录密码(必填)
|
||
*
|
||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||
*
|
||
* @route POST /api/v1/user/login
|
||
*/
|
||
public function login()
|
||
{
|
||
$account = trim((string) $this->request->post('account', ''));
|
||
$password = (string) $this->request->post('password', '');
|
||
|
||
$user = UserModel::getByAccount($account);
|
||
if (!$user) {
|
||
return $this->apiError('账号不存在');
|
||
}
|
||
if ($user->status != 1) {
|
||
return $this->apiError('账号已被禁用');
|
||
}
|
||
if ($user->isLocked()) {
|
||
return $this->apiError('账号已锁定,请稍后再试');
|
||
}
|
||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||
if (!$user->checkPassword($password)) {
|
||
$user->recordLoginFail($this->request->ip());
|
||
return $this->apiError('密码错误');
|
||
}
|
||
$user->recordLogin($this->request->ip());
|
||
|
||
return $this->issueToken($user, '登录成功');
|
||
}
|
||
|
||
/**
|
||
* 短信验证码登录 / 一键注册。
|
||
*
|
||
* 校验 mobile + captcha 通过后,若该手机号已注册则直接登录,
|
||
* 否则自动创建账号并登录。成功后签发 JWT。
|
||
*
|
||
* @param string $mobile 手机号(必填)
|
||
* @param string $captcha 短信验证码(必填)
|
||
*
|
||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||
*
|
||
* @route POST /api/v1/user/loginBySms
|
||
*/
|
||
public function loginBySms()
|
||
{
|
||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
|
||
if ($mobile === '' || ! SmsLib::check($mobile, $captcha, 'mobilelogin')) {
|
||
return $this->apiError('短信验证码不正确');
|
||
}
|
||
|
||
$user = UserModel::getByMobile($mobile);
|
||
if (!$user) {
|
||
// 该手机号未注册 → 自动注册
|
||
$user = new UserModel();
|
||
$user->account = 'u' . $mobile;
|
||
$user->password = mt_rand(100000, 999999); // 随机初始密码(仅短信登录,密码登录不可用)
|
||
$user->mobile = $mobile;
|
||
$user->nickname = '用户' . substr($mobile, -4);
|
||
$user->status = 1;
|
||
$user->save();
|
||
} elseif ($user->status != 1) {
|
||
return $this->apiError('账号已被禁用');
|
||
}
|
||
$user->recordLogin($this->request->ip());
|
||
|
||
return $this->issueToken($user, '登录成功');
|
||
}
|
||
|
||
/**
|
||
* 使用 refresh_token 刷新 access_token。
|
||
*
|
||
* 优先读取请求体中的 refresh_token,缺失时回退到 Header / Cookie。
|
||
* 调用 JwtService::refreshAccessToken 校验并签发新的 access_token。
|
||
*
|
||
* @param string $refresh_token 刷新令牌(请求体 / Header / Cookie)
|
||
*
|
||
* @return \think\Response JSON 响应,成功携带新的 token 对;失败返回 401
|
||
*
|
||
* @throws \Throwable 当 refresh_token 非法或过期时
|
||
*
|
||
* @route POST /api/v1/user/refresh
|
||
*/
|
||
public function refresh()
|
||
{
|
||
$refreshToken = trim((string) $this->request->post('refresh_token', ''));
|
||
if ($refreshToken === '') {
|
||
// 兼容从 Cookie / Header 读取
|
||
$refreshToken = $this->request->header('refresh_token', '') ?: $this->request->cookie('refresh_token', '');
|
||
}
|
||
if ($refreshToken === '') {
|
||
return $this->apiError('缺少 refresh_token', 401, null, 401);
|
||
}
|
||
try {
|
||
$data = JwtService::instance()->refreshAccessToken($refreshToken);
|
||
return $this->apiSuccess($data, '刷新成功');
|
||
} catch (\Throwable $e) {
|
||
return $this->apiError($e->getMessage(), 401, null, 401);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 退出登录。
|
||
*
|
||
* 系统采用无状态 JWT,服务端不维护会话;客户端收到成功响应后自行丢弃本地 token 即可。
|
||
*
|
||
* @return \think\Response JSON 响应,成功提示
|
||
*
|
||
* @route POST /api/v1/user/logout
|
||
*/
|
||
public function logout()
|
||
{
|
||
return $this->apiSuccess([], '已登出');
|
||
}
|
||
|
||
/**
|
||
* 获取当前登录会员资料(需登录)。
|
||
*
|
||
* 返回会员模型数组(password 等敏感字段已由模型 hidden 自动隐藏)。
|
||
*
|
||
* @return \think\Response JSON 响应,携带会员信息数组
|
||
*
|
||
* @route GET /api/v1/user/info
|
||
*/
|
||
public function info()
|
||
{
|
||
$user = $this->auth->model;
|
||
return $this->apiSuccess($user->toArray());
|
||
}
|
||
|
||
/**
|
||
* 修改当前会员资料(需登录)。
|
||
*
|
||
* 仅更新传入的非空字段(nickname / avatar / email),
|
||
* 邮箱需通过格式校验且未被其他会员占用。
|
||
*
|
||
* @param string $nickname 昵称(可选)
|
||
* @param string $avatar 头像地址(可选)
|
||
* @param string $email 邮箱(可选,唯一)
|
||
*
|
||
* @return \think\Response JSON 响应,携带更新后的会员信息
|
||
*
|
||
* @route POST /api/v1/user/updateProfile
|
||
*/
|
||
public function updateProfile()
|
||
{
|
||
$user = $this->auth->model;
|
||
$nickname = trim((string) $this->request->post('nickname', ''));
|
||
$avatar = trim((string) $this->request->post('avatar', ''));
|
||
$email = trim((string) $this->request->post('email', ''));
|
||
|
||
if ($nickname !== '') {
|
||
$user->nickname = $nickname;
|
||
}
|
||
if ($avatar !== '') {
|
||
$user->avatar = $avatar;
|
||
}
|
||
if ($email !== '') {
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
return $this->apiError('邮箱格式不正确');
|
||
}
|
||
$exists = UserModel::getByEmail($email);
|
||
if ($exists && $exists->uid != $user->uid) {
|
||
return $this->apiError('该邮箱已被占用');
|
||
}
|
||
$user->email = $email;
|
||
}
|
||
$user->save();
|
||
|
||
return $this->apiSuccess($user->toArray(), '保存成功');
|
||
}
|
||
|
||
/**
|
||
* 绑定手机号(需登录)。
|
||
*
|
||
* 校验新手机号 + 短信验证码(事件 bindmobile)通过,且账号未绑定手机号、号码未被占用时写入。
|
||
* 已绑定手机号的账号请使用 changeMobile 更换。
|
||
*
|
||
* @param string $mobile 手机号(必填)
|
||
* @param string $captcha 短信验证码(必填,事件 bindmobile)
|
||
*
|
||
* @return \think\Response JSON 响应,携带绑定后的手机号
|
||
*
|
||
* @route POST /api/v1/user/bindMobile
|
||
*/
|
||
public function bindMobile()
|
||
{
|
||
$user = $this->auth->model;
|
||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
|
||
if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) {
|
||
return $this->apiError('手机号格式不正确');
|
||
}
|
||
if ($captcha === '') {
|
||
return $this->apiError('短信验证码不能为空');
|
||
}
|
||
if ($user->mobile) {
|
||
return $this->apiError('该账号已绑定手机号,如需更换请使用更换接口');
|
||
}
|
||
if (! SmsLib::check($mobile, $captcha, 'bindmobile')) {
|
||
return $this->apiError('短信验证码不正确');
|
||
}
|
||
if (UserModel::getByMobile($mobile)) {
|
||
return $this->apiError('该手机号已被其他账号绑定');
|
||
}
|
||
$user->mobile = $mobile;
|
||
$user->save();
|
||
SmsLib::flush($mobile, 'bindmobile');
|
||
|
||
return $this->apiSuccess(['mobile' => $user->mobile], '绑定成功');
|
||
}
|
||
|
||
/**
|
||
* 更换绑定手机号(需登录)。
|
||
*
|
||
* 仅需新手机号短信验证码(事件 changemobile),无需旧号验证码,
|
||
* 旧手机号不可用时仍可换绑。新号被其他账号占用时拒绝。
|
||
*
|
||
* @param string $mobile 新手机号(必填)
|
||
* @param string $captcha 新手机号收到的短信验证码(必填,事件 changemobile)
|
||
*
|
||
* @return \think\Response JSON 响应,携带绑定后的手机号
|
||
*
|
||
* @route POST /api/v1/user/changeMobile
|
||
*/
|
||
public function changeMobile()
|
||
{
|
||
$user = $this->auth->model;
|
||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
|
||
if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) {
|
||
return $this->apiError('手机号格式不正确');
|
||
}
|
||
if ($captcha === '') {
|
||
return $this->apiError('短信验证码不能为空');
|
||
}
|
||
if (! $user->mobile) {
|
||
return $this->apiError('该账号未绑定手机号,请使用绑定接口');
|
||
}
|
||
if ($user->mobile === $mobile) {
|
||
return $this->apiError('新手机号与当前绑定手机号相同');
|
||
}
|
||
if (! SmsLib::check($mobile, $captcha, 'changemobile')) {
|
||
return $this->apiError('短信验证码不正确');
|
||
}
|
||
$exists = UserModel::getByMobile($mobile);
|
||
if ($exists && $exists->uid != $user->uid) {
|
||
return $this->apiError('该手机号已被其他账号绑定');
|
||
}
|
||
$user->mobile = $mobile;
|
||
$user->save();
|
||
SmsLib::flush($mobile, 'changemobile');
|
||
|
||
return $this->apiSuccess(['mobile' => $user->mobile], '更换成功');
|
||
}
|
||
|
||
/**
|
||
* 修改密码(需登录)。
|
||
*
|
||
* 先校验原密码正确,再校验新密码长度(至少 6 位),通过后通过 resetPassword 更新。
|
||
*
|
||
* @param string $oldpassword 原密码(必填)
|
||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||
*
|
||
* @return \think\Response JSON 响应,成功提示
|
||
*
|
||
* @route POST /api/v1/user/changePwd
|
||
*/
|
||
public function changePwd()
|
||
{
|
||
$old = (string) $this->request->post('oldpassword', '');
|
||
$new = (string) $this->request->post('newpassword', '');
|
||
|
||
$user = $this->auth->model;
|
||
if (!$user->checkPassword($old)) {
|
||
return $this->apiError('原密码错误');
|
||
}
|
||
if (strlen($new) < 6) {
|
||
return $this->apiError('新密码至少 6 位');
|
||
}
|
||
$user->resetPassword($new);
|
||
|
||
return $this->apiSuccess([], '密码修改成功');
|
||
}
|
||
|
||
/**
|
||
* 短信验证码找回密码(公开)。
|
||
*
|
||
* 校验 mobile + captcha(事件 resetpwd)通过后,若该手机号已注册则重置其密码。
|
||
*
|
||
* @param string $mobile 手机号(必填)
|
||
* @param string $captcha 短信验证码(必填)
|
||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||
*
|
||
* @return \think\Response JSON 响应,成功提示
|
||
*
|
||
* @route POST /api/v1/user/resetPwdBySms
|
||
*/
|
||
public function resetPwdBySms()
|
||
{
|
||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
$new = (string) $this->request->post('newpassword', '');
|
||
|
||
if ($new === '' || strlen($new) < 6) {
|
||
return $this->apiError('新密码至少 6 位');
|
||
}
|
||
if (! SmsLib::check($mobile, $captcha, 'resetpwd')) {
|
||
return $this->apiError('短信验证码不正确');
|
||
}
|
||
$user = UserModel::getByMobile($mobile);
|
||
if (!$user) {
|
||
return $this->apiError('该手机号未注册');
|
||
}
|
||
$user->resetPassword($new);
|
||
|
||
return $this->apiSuccess([], '密码重置成功');
|
||
}
|
||
|
||
/**
|
||
* 邮箱验证码找回密码(公开)。
|
||
*
|
||
* 校验 email + captcha(事件 resetpwd)通过后,若该邮箱已注册则重置其密码。
|
||
*
|
||
* @param string $email 邮箱(必填)
|
||
* @param string $captcha 邮箱验证码(必填)
|
||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||
*
|
||
* @return \think\Response JSON 响应,成功提示
|
||
*
|
||
* @route POST /api/v1/user/resetPwdByEmail
|
||
*/
|
||
public function resetPwdByEmail()
|
||
{
|
||
$email = trim((string) $this->request->post('email', ''));
|
||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||
$new = (string) $this->request->post('newpassword', '');
|
||
|
||
if ($new === '' || strlen($new) < 6) {
|
||
return $this->apiError('新密码至少 6 位');
|
||
}
|
||
if (! EmailLib::instance()->check($email, $captcha, 'resetpwd')) {
|
||
return $this->apiError('邮箱验证码不正确');
|
||
}
|
||
$user = UserModel::getByEmail($email);
|
||
if (!$user) {
|
||
return $this->apiError('该邮箱未注册');
|
||
}
|
||
$user->resetPassword($new);
|
||
|
||
return $this->apiSuccess([], '密码重置成功');
|
||
}
|
||
|
||
/**
|
||
* 签发 JWT 并通过标准响应返回会员信息。
|
||
*
|
||
* 调用 JwtService::createToken 生成 access_token / refresh_token(由 Result 自动随响应输出)。
|
||
*
|
||
* @param UserModel $user 会员模型
|
||
* @param string $msg 成功提示语
|
||
*
|
||
* @return \think\Response JSON 响应,携带会员信息与 token
|
||
*/
|
||
protected function issueToken(UserModel $user, string $msg = 'success')
|
||
{
|
||
JwtService::instance()->createToken([
|
||
'uid' => $user->uid,
|
||
'account' => $user->account,
|
||
]);
|
||
$data = $user->toArray();
|
||
return $this->apiSuccess($data, $msg);
|
||
}
|
||
}
|