Files
YwxAppThink/app/api/controller/v1/User.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

486 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}