소스 검색

1.新增微信登录接口
2.配置微信登录相关中间件和配置文件
3.新增微信用户修改账号接口

lh 12 시간 전
부모
커밋
c0f2b69bc6

+ 45 - 0
app/Cache/WechatStateCache.php

@@ -0,0 +1,45 @@
+<?php
+
+
+namespace App\Cache;
+
+
+use App\Libs\Utils;
+use Illuminate\Support\Facades\Redis;
+
+class WechatStateCache
+{
+    /**
+     * 写入扫码 state
+     *
+     * @param string $state
+     * @param int    $ttl 有效期(秒)
+     * @return void
+     */
+    public static function set(string $state, int $ttl)
+    {
+        Redis::setex(Utils::getCacheKey('wechat.state', [$state]), $ttl, '1');
+    }
+
+    /**
+     * 校验并消费 state(一次性)
+     *
+     * @param string $state
+     * @return bool
+     */
+    public static function pull(string $state): bool
+    {
+        if ($state === '') {
+            return false;
+        }
+
+        $cacheKey = Utils::getCacheKey('wechat.state', [$state]);
+        $value    = Redis::get($cacheKey);
+        if ($value === null || $value === '') {
+            return false;
+        }
+
+        Redis::del($cacheKey);
+        return true;
+    }
+}

+ 15 - 0
app/Http/Controllers/Account/AccountController.php

@@ -123,4 +123,19 @@ class AccountController extends BaseController
         $user = $this->accountService->userInfo();
         return $this->success($user);
     }
+
+    /**
+     * 修改账号(仅允许修改一次)
+     *
+     * @param Request $request
+     * @return mixed
+     * @throws ApiException
+     */
+    public function updateAccount(Request $request)
+    {
+        $account = (string)getProp($request->all(), 'account', '');
+        $this->accountService->updateAccount((int)Site::getUid(), $account);
+
+        return $this->success(['success' => 1]);
+    }
 }

+ 92 - 0
app/Http/Controllers/Account/WechatController.php

@@ -0,0 +1,92 @@
+<?php
+
+
+namespace App\Http\Controllers\Account;
+
+
+use App\Cache\WechatStateCache;
+use App\Libs\ApiResponse;
+use App\Services\Account\AccountService;
+use App\Services\Account\WechatService;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller as BaseController;
+
+class WechatController extends BaseController
+{
+    use ApiResponse;
+
+    protected $wechatService;
+    protected $accountService;
+
+    public function __construct(
+        WechatService $wechatService,
+        AccountService $accountService
+    ) {
+        $this->wechatService  = $wechatService;
+        $this->accountService = $accountService;
+    }
+
+    /**
+     * 微信扫码登录入口:生成 state 后跳转到微信官方二维码页
+     *
+     * @return \Illuminate\Http\RedirectResponse
+     */
+    public function login()
+    {
+        $state = md5(uniqid((string)mt_rand(), true));
+        WechatStateCache::set($state, (int)config('wechat.state_ttl', 600));
+
+        return redirect()->away($this->wechatService->buildAuthUrl($state));
+    }
+
+    /**
+     * 微信扫码回调:换取用户、注册/登录并带回 token
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\RedirectResponse
+     */
+    public function callback(Request $request)
+    {
+        $code     = trim((string)$request->get('code', ''));
+        $state    = trim((string)$request->get('state', ''));
+        $frontend = (string)config('wechat.web.frontend_redirect', '/');
+
+        try {
+            if ($state === '' || !WechatStateCache::pull($state)) {
+                throw new \RuntimeException('登录已失效,请重新扫码');
+            }
+            if ($code === '') {
+                throw new \RuntimeException('未获取到微信授权码');
+            }
+
+            list($uid, $isNew) = $this->wechatService->loginByCode($code);
+            $tokenData         = $this->accountService->issueTokenForUser((int)$uid);
+
+            return redirect()->away($this->appendQuery($frontend, [
+                'd-token' => $tokenData['token'],
+                'uid'     => (int)$uid,
+                'is_new'  => $isNew ? 1 : 0,
+            ]));
+        } catch (\Throwable $e) {
+            dLog('wechat')->info('wechat callback error', [$e->getMessage()]);
+
+            return redirect()->away($this->appendQuery($frontend, [
+                'wechat_error' => $e->getMessage(),
+            ]));
+        }
+    }
+
+    /**
+     * 追加 query 参数
+     *
+     * @param string $url
+     * @param array  $params
+     * @return string
+     */
+    private function appendQuery(string $url, array $params): string
+    {
+        $separator = strpos($url, '?') === false ? '?' : '&';
+
+        return $url . $separator . http_build_query($params);
+    }
+}

+ 79 - 0
app/Services/Account/AccountService.php

@@ -149,6 +149,85 @@ class AccountService
     }
 
     /**
+     * 为用户签发登录态(注册成功或第三方登录后调用)
+     *
+     * @param int $uid
+     * @return array
+     */
+    public function issueTokenForUser(int $uid): array
+    {
+        $user = DB::table('mp_manage_users')->where('id', $uid)->where('is_deleted', 0)->first();
+        if (!$user) {
+            Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
+        }
+        if ((int)getProp($user, 'is_enabled') === 0) {
+            Utils::throwError('20003:用户已被禁用');
+        }
+
+        $tokenData = $this->buildTokenData();
+        DB::table('mp_manage_users')->where('id', $uid)->update([
+            'token'                    => $tokenData['token'],
+            'token_expires_at'         => $tokenData['token_expires_at'],
+            'refresh_token'            => $tokenData['refresh_token'],
+            'refresh_token_expires_at' => $tokenData['refresh_token_expires_at'],
+            'updated_at'               => date('Y-m-d H:i:s'),
+        ]);
+
+        return array_merge([
+            'uid'      => $uid,
+            'nickname' => (string)getProp($user, 'nickname'),
+            'role'     => (string)getProp($user, 'role'),
+        ], $tokenData);
+    }
+
+    /**
+     * 修改账号(仅微信注册用户,且每个用户仅允许修改一次)
+     *
+     * @param int    $uid
+     * @param string $newAccount
+     * @return bool
+     */
+    public function updateAccount(int $uid, string $newAccount): bool
+    {
+        $newAccount = trim($newAccount);
+        if ($newAccount === '') {
+            Utils::throwError('1002:请输入新账号');
+        }
+        if (mb_strlen($newAccount) > 32) {
+            Utils::throwError('1002:账号不能超过32个字符');
+        }
+        if (!preg_match('/^[A-Za-z0-9_]+$/', $newAccount)) {
+            Utils::throwError('1002:账号只能包含字母、数字和下划线');
+        }
+
+        return DB::transaction(function () use ($uid, $newAccount) {
+            $user = DB::table('mp_manage_users')->where('id', $uid)->where('is_deleted', 0)->lockForUpdate()->first();
+            if (!$user) {
+                Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
+            }
+            if ((string)getProp($user, 'wechat_openid') === '' && (string)getProp($user, 'wechat_unionid') === '') {
+                Utils::throwError('1005:仅微信注册用户可修改账号');
+            }
+            if ((int)getProp($user, 'account_modified') === 1) {
+                Utils::throwError('1002:账号只能修改一次');
+            }
+            if ($newAccount === (string)getProp($user, 'account')) {
+                Utils::throwError('1002:新账号与当前账号相同');
+            }
+            // 账号全局唯一:包含软删除记录,避免历史占用后再次出现同名歧义
+            if (DB::table('mp_manage_users')->where('account', $newAccount)->where('id', '<>', $uid)->exists()) {
+                Utils::throwError('1002:账号已存在');
+            }
+
+            return (bool)DB::table('mp_manage_users')->where('id', $uid)->update([
+                'account'          => $newAccount,
+                'account_modified' => 1,
+                'updated_at'       => date('Y-m-d H:i:s'),
+            ]);
+        });
+    }
+
+    /**
      * 生成 token、refresh_token 及其过期时间
      *
      * @param string $token 需要沿用的 token(传空则重新生成)

+ 198 - 0
app/Services/Account/WechatService.php

@@ -0,0 +1,198 @@
+<?php
+
+
+namespace App\Services\Account;
+
+
+use App\Libs\Utils;
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\DB;
+
+class WechatService
+{
+    private $salt = 'bXBfYXVkaW8=';
+
+    /**
+     * 构造微信官方扫码登录地址(跳转后由微信页面自动渲染二维码)
+     *
+     * @param string $state
+     * @return string
+     */
+    public function buildAuthUrl(string $state): string
+    {
+        $config = (array)config('wechat.web');
+        $query  = http_build_query([
+            'appid'            => $config['appid'],
+            'redirect_uri'     => $config['redirect'],
+            'response_type'    => 'code',
+            'scope'            => 'snsapi_login',
+            'state'            => $state,
+            'connect_redirect' => 1,
+        ]);
+
+        return 'https://open.weixin.qq.com/connect/qrconnect?' . $query . '#wechat_redirect';
+    }
+
+    /**
+     * 用授权 code 换取微信用户,并完成登录/注册
+     *
+     * @param string $code
+     * @return array [uid, 是否新注册]
+     */
+    public function loginByCode(string $code): array
+    {
+        $config = (array)config('wechat.web');
+        if (empty($config['secret'])) {
+            Utils::throwError('1002:微信登录未配置AppSecret');
+        }
+
+        $token = $this->request('https://api.weixin.qq.com/sns/oauth2/access_token', [
+            'appid'      => $config['appid'],
+            'secret'     => $config['secret'],
+            'code'       => $code,
+            'grant_type' => 'authorization_code',
+        ]);
+        if (empty($token['access_token']) || empty($token['openid'])) {
+            dLog('wechat')->info('wechat access_token error', [$token]);
+            Utils::throwError('1002:微信授权失败,请重试');
+        }
+
+        $openid  = (string)$token['openid'];
+        $unionid = (string)getProp($token, 'unionid', '');
+
+        $userInfo = $this->request('https://api.weixin.qq.com/sns/userinfo', [
+            'access_token' => $token['access_token'],
+            'openid'       => $openid,
+            'lang'         => 'zh_CN',
+        ]);
+        if (isset($userInfo['errcode'])) {
+            dLog('wechat')->info('wechat userinfo error', [$userInfo]);
+            Utils::throwError('1002:微信授权失败,请重试');
+        }
+        if ($unionid === '') {
+            $unionid = (string)getProp($userInfo, 'unionid', '');
+        }
+
+        return $this->findOrCreateUser(
+            $openid,
+            $unionid,
+            (string)getProp($userInfo, 'nickname', ''),
+            (string)getProp($userInfo, 'headimgurl', '')
+        );
+    }
+
+    /**
+     * 按 unionid/ openid 匹配用户,不存在则注册
+     *
+     * @param string $openid
+     * @param string $unionid
+     * @param string $nickname
+     * @param string $avatar
+     * @return array
+     */
+    private function findOrCreateUser(string $openid, string $unionid, string $nickname, string $avatar): array
+    {
+        $user = null;
+        if ($unionid !== '') {
+            $user = DB::table('mp_manage_users')
+                ->where('is_deleted', 0)
+                ->where('wechat_unionid', $unionid)
+                ->first();
+        }
+        if (!$user) {
+            $user = DB::table('mp_manage_users')
+                ->where('is_deleted', 0)
+                ->where('wechat_openid', $openid)
+                ->first();
+        }
+
+        if ($user) {
+            $update = ['updated_at' => date('Y-m-d H:i:s')];
+            if ((string)getProp($user, 'avatar') === '' && $avatar !== '') {
+                $update['avatar'] = $avatar;
+            }
+            if ($unionid !== '' && (string)getProp($user, 'wechat_unionid') === '') {
+                $update['wechat_unionid'] = $unionid;
+            }
+            DB::table('mp_manage_users')->where('id', getProp($user, 'id'))->update($update);
+
+            return [(int)getProp($user, 'id'), false];
+        }
+
+        $cpid     = (int)config('wechat.cp_id', 4);
+        $account  = $this->generateAccount();
+        $nickname = trim($nickname);
+
+        $uid = DB::transaction(function () use ($account, $nickname, $avatar, $cpid, $openid, $unionid) {
+            $randomPwd = makeRandStr(16);
+
+            $uid = DB::table('mp_manage_users')->insertGetId([
+                'account'         => $account,
+                'nickname'        => $nickname,
+                'avatar'          => $avatar,
+                'o_pass'          => '',
+                'pwd'             => md5($randomPwd . $this->salt),
+                'role'            => 'user',
+                'cpid'            => $cpid,
+                'points'          => 0,
+                'is_enabled'      => 1,
+                'is_deleted'      => 0,
+                'wechat_openid'   => $openid,
+                'wechat_unionid'  => $unionid !== '' ? $unionid : null,
+                'wechat_appid'    => (string)config('wechat.web.appid'),
+                'account_modified'=> 0,
+                'created_at'      => date('Y-m-d H:i:s'),
+                'updated_at'      => date('Y-m-d H:i:s'),
+            ]);
+
+            if ($nickname === '') {
+                DB::table('mp_manage_users')->where('id', $uid)->update([
+                    'nickname'   => '微信用户' . $uid,
+                    'updated_at' => date('Y-m-d H:i:s'),
+                ]);
+            }
+
+            return $uid;
+        });
+
+        return [(int)$uid, true];
+    }
+
+    /**
+     * 生成 wechat_ 前缀的 16 位随机账号
+     *
+     * @return string
+     */
+    private function generateAccount(): string
+    {
+        for ($i = 0; $i < 5; $i++) {
+            $account = 'wechat_' . makeRandStr(16);
+            if (!DB::table('mp_manage_users')->where('account', $account)->exists()) {
+                return $account;
+            }
+        }
+
+        Utils::throwError('1002:账号生成失败,请重试');
+    }
+
+    /**
+     * 请求微信接口
+     *
+     * @param string $url
+     * @param array  $query
+     * @return array
+     */
+    private function request(string $url, array $query): array
+    {
+        try {
+            $client = new Client(['timeout' => 10]);
+            $resp   = $client->get($url, ['query' => $query]);
+            $data   = json_decode((string)$resp->getBody(), true);
+
+            return is_array($data) ? $data : [];
+        } catch (\Throwable $e) {
+            dLog('wechat')->info('wechat request error', [$url, $e->getMessage()]);
+            Utils::throwError('1002:微信授权请求失败,请重试');
+        }
+    }
+}

+ 23 - 0
config/wechat.php

@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * 微信扫码登录配置(开放平台网站应用,scope=snsapi_login)
+ */
+return [
+
+    'web' => [
+        'appid'            => (string)env('WECHAT_WEB_APPID', 'wx5d87ef503e1213d5'),
+        'secret'           => (string)env('WECHAT_WEB_SECRET', ''),
+        // 授权回调域只允许 oauth.ycsd.cn,故指向 oauth 网关中转,再由网关透传到本项目回调
+        'redirect'         => (string)env('WECHAT_WEB_REDIRECT', 'https://oauth.ycsd.cn/api/wechat_relay/anime'),
+        // 扫码登录成功后前端落地页,token 会以 query 参数 d-token 带回
+        'frontend_redirect'=> (string)env('WECHAT_WEB_FRONTEND_REDIRECT', '/'),
+    ],
+
+    // state 有效期(秒)
+    'state_ttl' => 600,
+
+    // 微信扫码注册用户归属的公司ID
+    'cp_id' => 4,
+
+];

+ 6 - 0
routes/api.php

@@ -2,6 +2,7 @@
 
 use App\Http\Controllers\Account\AccountController;
 use App\Http\Controllers\Account\CaptchaController;
+use App\Http\Controllers\Account\WechatController;
 use App\Http\Controllers\DeepSeek\DeepSeekController;
 use App\Http\Controllers\Book\BookController;
 use App\Http\Controllers\Timbre\TimbreController;
@@ -111,6 +112,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         Route::post('edit', [ManageUserController::class, 'edit']);  // 修改用户(昵称/密码/是否启用/角色)
         Route::get('delete', [ManageUserController::class, 'delete']); // 删除用户(超管删 admin/user,管理员删自己的组员)
         Route::get('roleOptions', [ManageUserController::class, 'roleOptions']); // 角色可选项(超管 admin/user,管理员仅 user)
+        Route::post('updateAccount', [AccountController::class, 'updateAccount']);   // 修改账号(仅一次)
     });
 
     Route::group(['prefix' => 'company'], function () {
@@ -348,6 +350,10 @@ Route::get('login', [AccountController::class, 'login']); // 登录
 Route::get('logout', [AccountController::class, 'logout']); // 退出
 Route::post('refreshToken', [AccountController::class, 'refreshToken']); // 通过refresh_token刷新token
 
+// 微信扫码登录(免登录)
+Route::get('wechat/login', [WechatController::class, 'login']); // 跳转微信官方扫码页
+Route::get('wechat/callback', [WechatController::class, 'callback']); // 微信扫码回调
+
 // seedance回调
 Route::any('video/seedanceCallback', [VideoGenerationController::class, 'seedanceCallback']);
 Route::any('{slug}',[AccountController::class, 'index'])->where('slug', '(.*)?');