Files
douba_shop/app/services/wechat/WechatServices.php
Karlcarlo 49c4ce8183 feat(App身份): 新增 app_bind / app_identity 两个接口
- POST /api/wechat/app_bind(需登录):code → sns/oauth2/access_token
  → 落 wechat_user(user_type='app'),绑定到当前 uid;openid 唯一索引冲突时拒绝抢绑
- GET /api/wechat/app_identity(需登录):返回注入 H5 的规范身份
  ① 当前 uid 的 routine openid ② 当前 uid 的 app openid
  ③ app unionid 命中其他 uid 的 routine openid ④ 空(前端引导授权)
- 供 App(Android 一期)在短信登录后取 openid,宿主注入规则不变

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 19:55:56 +08:00

637 lines
24 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
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\services\wechat;
use app\services\BaseServices;
use app\dao\wechat\WechatUserDao;
use app\services\user\UserServices;
use app\services\user\UserVisitServices;
use crmeb\services\CacheService;
use crmeb\services\CacheService as Cache;
use crmeb\services\wechat\config\OpenAppConfig;
use crmeb\services\wechat\OfficialAccount;
use crmeb\services\wechat\Work;
use crmeb\utils\Canvas;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use GuzzleHttp\Exception\GuzzleException;
use think\annotation\Inject;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\DbException;
use think\exception\ValidateException;
use think\facade\Log;
/**
*
* Class WechatServices
* @package app\services\wechat
* @mixin WechatUserDao
*/
class WechatServices extends BaseServices
{
/**
* @var WechatUserDao
*/
#[Inject]
protected WechatUserDao $dao;
/**
* 微信公众号服务
* @return \think\Response
* @throws BadRequestException
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws \ReflectionException
*/
public function serve()
{
ob_clean();
$res = OfficialAccount::serve();
//
return $res;
}
/**
* 企业微信服务
* @return \think\Response
* @throws BadRequestException
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws \ReflectionException
*/
public function workServe()
{
ob_clean();
return Work::serve();
}
/**
* 公众号权限配置信息获取
* @param $url
* @return array
*/
public function config($url): array
{
return OfficialAccount::jsSdk($url);
}
/**
* 获取授权信息
* @param string $code
* @return array
*/
public function getAuthWechatInfo()
{
try {
$userInfoConfig = OfficialAccount::tokenFromCode();
} catch (\Throwable $e) {
\think\facade\Log::error(
json_encode([
'error' => '授权失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
);
throw new ValidateException('授权失败');
}
if (!isset($userInfoConfig['openid']) || !$userInfoConfig['openid']) {
throw new ValidateException('openid获取失败');
}
return $userInfoConfig;
}
/**
* 获取返回信息
* @param $user
* @param string $userType
* @param string $openid
* @return array
*/
public function getReturnInfo($user, string $userType = 'wechat', string $openid = '')
{
if (!$user || !isset($user['uid']) || !$user['uid']) {
throw new ValidateException('获取用户信息失败');
}
$uid = (int)$user['uid'];
$token = $this->createToken($uid, $userType, $user['pwd'] ?? '', [
'phone' => $user['phone'] ?? '',
'openid' => $openid,
]);
if (!$token) {
throw new ValidateException('登录失败!');
}
/** @var UserVisitServices $visitServices */
$visitServices = app()->make(UserVisitServices::class);
$visitServices->loginSaveVisit($user);
$token['store_user_avatar'] = 0;
$token['userInfo'] = is_object($user) && method_exists($user, 'toArray') ? $user->toArray() : $user;
$token['expires_time'] = $token['params']['exp'] ?? 0;
// 用户登录成功事件
event('user.login', [$uid, app('request')->ip()]);
return $token;
}
/**
* 公众号授权登录,返回是否需要绑定手机号token
* @param $spread_uid
* @return array
* @throws DataNotFoundException
* @throws InvalidConfigException
* @throws ModelNotFoundException
* @throws \think\db\exception\DbException
*/
public function authLogin($spread_uid = '')
{
$wechatInfo = OfficialAccount::userFromCode();
if (!isset($wechatInfo['nickname'])) {
$wechatInfo = OfficialAccount::instance()->user()->get($wechatInfo['openid']);
if (!isset($wechatInfo['nickname']))
throw new ValidateException('授权失败');
if (isset($wechatInfo['tagid_list']))
$wechatInfo['tagid_list'] = implode(',', $wechatInfo['tagid_list']);
} else {
if (isset($wechatInfo['privilege'])) unset($wechatInfo['privilege']);
}
$wechatInfo['user_type'] = 'wechat';
$openid = $wechatInfo['openid'];
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
$user = $wechatUserServices->getAuthUserInfo($openid, 'wechat');
$createData = [$openid, $wechatInfo, $spread_uid, 'wechat', 'wechat'];
//获取是否强制绑定手机号
$storeUserMobile = sys_config('store_user_mobile');
if ($storeUserMobile && (($user && $user['phone'] == '') || !$user)) {
$userInfoKey = md5($openid . '_' . time() . '_wechat');
CacheService::set($userInfoKey, $createData, 7200);
return ['bindPhone' => true, 'key' => $userInfoKey];
}
if (!$user) {
$user = $wechatUserServices->wechatOauthAfter($createData);
} else {
$wechatUserServices->wechatUpdata([$user['uid'], $wechatInfo]);
}
return $this->getReturnInfo($user, 'wechat', $openid);
}
/**
* 公众号授权登陆
* @return mixed
* @throws DataNotFoundException
* @throws ModelNotFoundException
* @throws DbException|InvalidConfigException
*/
public function auth($spread_uid, $login_type)
{
$wechatInfo = $this->getAuthWechatInfo();
if (!isset($wechatInfo['nickname'])) {
$wechatInfo = OfficialAccount::instance()->user()->get($wechatInfo['openid'])->toArray();
if (!isset($wechatInfo['nickname']))
throw new ValidateException('授权失败');
if (isset($wechatInfo['tagid_list']))
$wechatInfo['tagid_list'] = implode(',', $wechatInfo['tagid_list']);
} else {
if (isset($wechatInfo['privilege'])) unset($wechatInfo['privilege']);
/** @var WechatUserServices $wechatUser */
$wechatUser = app()->make(WechatUserServices::class);
if (!$wechatUser->getOne(['openid' => $wechatInfo['openid']])) {
$wechatInfo['subscribe'] = 0;
}
}
$wechatInfo['user_type'] = 'wechat';
$openid = $wechatInfo['openid'];
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
$user = $wechatUserServices->getAuthUserInfo($openid, 'wechat');
$createData = [$openid, $wechatInfo, $spread_uid, $login_type, 'wechat'];
if (!$user) {
$user = $wechatUserServices->wechatOauthAfter($createData);
} else {
//更新用户信息
$wechatUserServices->wechatUpdata([$user['uid'], $wechatInfo]);
}
return $this->getReturnInfo($user, 'wechat', $openid);
}
/**
* 新公众号授权登录
* @param $spread_uid
* @param $login_type
* @return mixed
* @throws DataNotFoundException
* @throws InvalidConfigException
* @throws ModelNotFoundException
*/
public function newAuth($spread_uid, $login_type)
{
$wechatInfo = OfficialAccount::userFromCode();
if (!isset($wechatInfo['nickname'])) {
$wechatInfo = OfficialAccount::instance()->user()->get($wechatInfo['openid']);
if (!isset($wechatInfo['nickname']))
throw new ValidateException('授权失败');
if (isset($wechatInfo['tagid_list']))
$wechatInfo['tagid_list'] = implode(',', $wechatInfo['tagid_list']);
} else {
if (isset($wechatInfo['privilege'])) unset($wechatInfo['privilege']);
}
$wechatInfo['user_type'] = 'wechat';
$openid = $wechatInfo['openid'];
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
$user = $wechatUserServices->getAuthUserInfo($openid, 'wechat');
$createData = [$openid, $wechatInfo, $spread_uid, $login_type, 'wechat'];
//获取是否强制绑定手机号
$storeUserMobile = sys_config('store_user_mobile');
if ($storeUserMobile && !$user) {
$userInfoKey = md5($openid . '_' . time() . '_wechat');
Cache::setTokenBucket($userInfoKey, $createData, 7200);
return ['key' => $userInfoKey];
} else if (!$user) {
$user = $wechatUserServices->wechatOauthAfter($createData);
} else {
//更新用户信息
$wechatUserServices->wechatUpdata([$user['uid'], $wechatInfo]);
}
return $this->getReturnInfo($user, 'wechat', $openid);
}
/**
* 生成关注二维码图片
* @return array
* @throws ValidateException
*/
public function follow()
{
$canvas = Canvas::instance();
$path = 'uploads/follow/';
$imageType = 'jpg';
$name = 'follow';
$siteUrl = sys_config('site_url');
$imageUrl = $path . $name . '.' . $imageType;
$canvas->setImageUrl(public_path() . 'statics/qrcode/follow.png')->setImageHeight(720)->setImageWidth(500)->pushImageValue();
$wechatQrcode = sys_config('wechat_qrcode');
if (($strlen = stripos($wechatQrcode, 'uploads')) !== false) {
$wechatQrcode = substr($wechatQrcode, $strlen);
}
if (!$wechatQrcode)
throw new ValidateException('请上传二维码');
$canvas->setImageUrl($wechatQrcode)->setImageHeight(344)->setImageWidth(344)->setImageLeft(76)->setImageTop(76)->pushImageValue();
$image = $canvas->setFileName($name)->setImageType($imageType)->setPath($path)->setBackgroundWidth(500)->setBackgroundHeight(720)->starDrawChart();
return ['path' => $image ? $siteUrl . '/' . $image : ''];
}
/**
* 微信公众号静默授权
* @param $spread_uid
* @param bool $notLogin
* @return array
* @throws DataNotFoundException
* @throws ModelNotFoundException
*/
public function silenceAuth($spread_uid, bool $notLogin = false, string $snsapi = '')
{
if ($snsapi) {
$wechatInfoConfig = $this->getAuthWechatInfo();
} else {
$wechatInfoConfig = OfficialAccount::userFromCode();
}
$openid = $wechatInfoConfig['openid'];
try {
$wechatInfo = OfficialAccount::instance()->user()->get($wechatInfoConfig['openid'])->toArray();
} catch (\Throwable $e) {
$createData = [$openid, [], $spread_uid, '', 'wechat'];
$userInfoKey = md5($openid . '_' . time() . '_wechat');
Cache::setTokenBucket($userInfoKey, $createData, 7200);
return ['auth_login' => 1, 'key' => $userInfoKey];
}
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
$user = $wechatUserServices->getAuthUserInfo($openid, 'wechat');
if (!$user) {
$wechatInfo['headimgurl'] = isset($wechatInfo['headimgurl']) && $wechatInfo['headimgurl'] != '' ? $wechatInfo['headimgurl'] : sys_config('h5_avatar');
$createData = [$openid, $wechatInfo, $spread_uid, '', 'wechat'];
//获取是否强制绑定手机号
$storeUserMobile = sys_config('store_user_mobile');
if ($notLogin || $storeUserMobile) {
$userInfoKey = md5($openid . '_' . time() . '_wechat');
Cache::setTokenBucket($userInfoKey, $createData, 7200);
return ['auth_login' => 1, 'key' => $userInfoKey];
} else {
//写入用户信息
$user = $wechatUserServices->wechatOauthAfter($createData);
}
} else {
//更新用户信息
$wechatUserServices->wechatUpdata([$user['uid'], ['spread_uid' => $spread_uid]]);
}
return $this->getReturnInfo($user, 'wechat', $openid);
}
/**
* 微信公众号静默授权
* @param $spread
* @param $phone
* @return array
* @throws DataNotFoundException
* @throws ModelNotFoundException|\Psr\SimpleCache\InvalidArgumentException
*/
public function silenceAuthBindingPhone($key, $phone)
{
if (!$key) {
throw new ValidateException('请刷新页面或者重新授权');
}
[$openid, $wechatInfo, $spread_uid, $login_type, $userType] = $createData = CacheService::getTokenBucket($key);
if (!$createData) {
throw new ValidateException('请刷新页面或者重新授权');
}
$wechatInfo['phone'] = $phone;
/** @var WechatUserServices $wechatUser */
$wechatUser = app()->make(WechatUserServices::class);
//更新用户信息
$user = $wechatUser->wechatOauthAfter([$openid, $wechatInfo, $spread_uid, $login_type, $userType]);
return $this->getReturnInfo($user, $userType, $openid);
}
/**
* @param array $userData
* @param string $phone
* @param string $userType
* @return array
* @throws DataNotFoundException
* @throws ModelNotFoundException
*/
public function appAuth(array $userData, string $phone, string $userType = 'app')
{
$openid = $userData['openId'] ?? "";
$userInfo = [
'phone' => $phone,
'unionid' => $userData['unionId'] ?? '',
'headimgurl' => $userData['avatarUrl'] ?? '',
'nickname' => $userData['nickName'] ?? '',
'province' => $userData['province'] ?? '',
'country' => $userData['country'] ?? '',
'city' => $userData['city'] ?? '',
'openid' => $openid,
];
$login_type = $userType;
$spread_uid = $userInfo['spread_uid'] ?? "";
if (!$phone) {
//获取是否强制绑定手机号
$storeUserMobile = sys_config('store_user_mobile');
if ($userInfo['unionid'] && $storeUserMobile) {
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$uid = $this->dao->value(['unionid' => $userInfo['unionid'], 'is_del' => 0], 'uid');
$res = $userServices->value(['uid' => $uid], 'phone');
if (!$uid || !$res) {
return false;
}
}
//openid存在加数据情况
if ($openid) {
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$uid = $this->dao->value(['openid' => $openid, 'is_del' => 0], 'uid');
$res = $userServices->value(['uid' => $uid], 'phone');
if (!$uid || !$res) {
return false;
}
}
}
/** @var WechatUserServices $wechatUser */
$wechatUser = app()->make(WechatUserServices::class);
//更新用户信息
$user = $wechatUser->wechatOauthAfter([$openid, $userInfo, $spread_uid, $login_type, $userType]);
$token = $this->getReturnInfo($user, $userType, $openid);
$token['isbind'] = false;
return $token;
}
/**
* App 微信绑定:code → openid/unionid,并绑定到当前 uid
*
* App 端 uni.login({provider:'weixin'}) 拿 code;服务端用「开放平台移动应用」
* 的 appid/secret 调 sns/oauth2/access_token(与小程序 code2session 不是同一套)。
*
* ⚠️ wechat_user.openid 是唯一索引:
* · 同一 uid 重复绑定 → 更新 unionid;
* · 已被其他 uid 占用 → 报错,不静默抢绑(否则会把别人的账号顶掉)。
*
* @param int $uid
* @param string $code
* @return array{openid:string,unionid:string}
*/
public function appBindCode(int $uid, string $code): array
{
if ($uid <= 0) {
throw new ValidateException('未登录');
}
$code = trim($code);
if ($code === '') {
throw new ValidateException('缺少微信授权 code');
}
/** @var OpenAppConfig $cfg */
$cfg = app()->make(OpenAppConfig::class);
if (!$cfg->appId || !$cfg->secret) {
throw new ValidateException('未配置开放平台移动应用的 appid/secret');
}
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([
'appid' => $cfg->appId,
'secret' => $cfg->secret,
'code' => $code,
'grant_type' => 'authorization_code',
]);
$res = $this->appHttpGetJson($url);
if (empty($res['openid'])) {
$msg = (string)($res['errmsg'] ?? 'code 无效或已过期');
Log::warning('[app_bind] 换 openid 失败 uid=' . $uid . ' err=' . $msg);
throw new ValidateException('微信授权失败:' . $msg);
}
$openid = (string)$res['openid'];
$unionid = (string)($res['unionid'] ?? '');
$row = $this->dao->getOne(['openid' => $openid]);
if ($row && (int)$row['uid'] !== $uid) {
throw new ValidateException('该微信已绑定其他账号,请先在原账号解绑');
}
$data = [
'uid' => $uid,
'openid' => $openid,
'unionid' => $unionid,
'user_type' => 'app',
'is_del' => 0,
'add_time' => time(),
];
if ($row) {
$this->dao->update((int)$row['id'], $data, 'id');
} else {
$this->dao->save($data);
}
Log::info('[app_bind] uid=' . $uid . ' openid=' . substr($openid, 0, 6)
. '… unionid=' . ($unionid ? 'yes' : 'no'));
return ['openid' => $openid, 'unionid' => $unionid];
}
/**
* App 取规范身份(宿主注入 H5 用)
*
* 规则(2026-10-26):
* ① 当前 uid 有 routine(小程序)openid → 用它(老用户数据连续)
* ② 否则当前 uid 有 app openid → 用它
* ③ 否则若 app 的 unionid 能命中**其他 uid** 的 routine openid → 用那个
* (同一自然人先用小程序、后来用手机号注册 App 的场景)
* ④ 都没有 → openid 空,前端引导微信授权
*
* @param int $uid
* @return array{openid:string,unionid:string,source:string}
*/
public function appIdentity(int $uid): array
{
$empty = ['openid' => '', 'unionid' => '', 'source' => 'none'];
if ($uid <= 0) {
return $empty;
}
$rows = $this->dao->getList(['uid' => $uid, 'is_del' => 0], 'id,openid,unionid,user_type,add_time', 'id desc');
$routine = null;
$app = null;
foreach ($rows as $r) {
if (!$routine && $r['user_type'] === 'routine' && $r['openid']) {
$routine = $r;
}
if (!$app && $r['user_type'] === 'app' && $r['openid']) {
$app = $r;
}
if ($routine && $app) {
break;
}
}
if ($routine) {
return [
'openid' => (string)$routine['openid'],
'unionid' => (string)($routine['unionid'] ?: ($app['unionid'] ?? '')),
'source' => 'routine',
];
}
if ($app) {
$unionid = (string)$app['unionid'];
if ($unionid) {
$other = $this->dao->getOne(
['unionid' => $unionid, 'user_type' => 'routine', 'is_del' => 0],
'openid,unionid'
);
if ($other && !empty($other['openid'])) {
return [
'openid' => (string)$other['openid'],
'unionid' => $unionid,
'source' => 'routine-unionid',
];
}
}
return ['openid' => (string)$app['openid'], 'unionid' => $unionid, 'source' => 'app'];
}
return $empty;
}
/**
* 微信接口 GET(curl;5s 连接 / 8s 总超时)
* @param string $url
* @return array
*/
private function appHttpGetJson(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new ValidateException('微信接口请求失败:' . $err);
}
$json = json_decode((string)$body, true);
if (!is_array($json)) {
throw new ValidateException('微信接口返回异常');
}
return $json;
}
/**
* 是否关注
* @param int $uid
* @return bool
*/
public function isSubscribe(int $uid)
{
if ($uid) {
$subscribe = (bool)$this->dao->value(['uid' => $uid], 'subscribe');
} else {
$subscribe = true;
}
return $subscribe;
}
/**
* 更新公众号用户信息
* @param int $uid
* @return array
*/
public function updateUserInfo(int $uid)
{
$wechatInfoConfig = OfficialAccount::userFromCode();
$openid = $wechatInfoConfig['openid'] ?? null;
try {
$wechatInfo = OfficialAccount::instance()->user()->get($wechatInfoConfig['openid']);
} catch (\Throwable $e) {
throw new ValidateException('更新公众号用户信息失败:' . $e->getMessage());
}
if (!$openid) {
throw new ValidateException('更新公众号用户信息失败:没有获取到openid');
}
$wechatInfo['nickname'] = $wechatInfoConfig['nickname'] ?? $wechatInfo['nickname'];
$wechatInfo['headimgurl'] = $wechatInfoConfig['headimgurl'] ?? $wechatInfo['headimgurl'];
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
$id = $wechatUserServices->value(['openid' => $openid, 'uid' => $uid, 'user_type' => 'wechat'], 'id');
if (!$id) {
throw new ValidateException('没有查到用户信息');
}
/** @var UserServices $userService */
$userService = app()->make(UserServices::class);
$user = $userService->getUserInfo($uid);
if (isset($user['status']) && !$user['status']) {
throw new ValidateException('您已被禁止登录,请联系管理员');
}
if ($user) {
//更新用户信息
$wechatUserServices->wechatUpdata([$user['uid'], $wechatInfo]);
}
return [
'nickname' => $wechatInfo['nickname'],
'avatar' => $wechatInfo['headimgurl'],
'is_complete' => 1
];
}
}