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>
This commit is contained in:
@@ -147,6 +147,34 @@ class Wechat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App 微信绑定(需登录)
|
||||
* body: code(uni.login provider=weixin 拿到的 code)
|
||||
* 返回 {openid, unionid}:绑定到当前登录 uid
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function appBind(Request $request)
|
||||
{
|
||||
[$code] = $request->postMore([['code', '']], true);
|
||||
$data = $this->services->appBindCode((int)$request->uid(), (string)$code);
|
||||
return app('json')->success('绑定成功', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* App 取规范身份(需登录)
|
||||
* 返回 {openid, unionid, source}:routine 优先,其次 app,最后按 unionid 反查
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function appIdentity(Request $request)
|
||||
{
|
||||
$data = $this->services->appIdentity((int)$request->uid());
|
||||
return app('json')->success('ok', $data);
|
||||
}
|
||||
|
||||
public function follow()
|
||||
{
|
||||
$data = $this->services->follow();
|
||||
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -419,6 +420,159 @@ class WechatServices extends BaseServices
|
||||
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
|
||||
|
||||
@@ -441,6 +441,10 @@ Route::group('api', function () {
|
||||
Route::post('card/gift_info', 'v1.activity.CardGift/giftInfo')->name('礼品卡详情');//礼品卡详情
|
||||
Route::post('card/receive', 'v1.activity.CardGift/receive')->name('礼品卡兑换');//礼品卡兑换
|
||||
|
||||
//App 身份(2026-10-26):绑定微信拿 unionid / 取宿主注入用的规范 openid
|
||||
Route::post('wechat/app_bind', 'v1.wechat.Wechat/appBind')->name('appBind');//App 绑定微信(code 换 openid/unionid)
|
||||
Route::get('wechat/app_identity', 'v1.wechat.Wechat/appIdentity')->name('appIdentity');//App 取规范身份
|
||||
|
||||
|
||||
|
||||
})->middleware(StationOpenMiddleware::class)->middleware(AuthTokenMiddleware::class, true);
|
||||
|
||||
Reference in New Issue
Block a user