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:
2026-10-26 19:55:56 +08:00
parent 9a1c922642
commit 49c4ce8183
3 changed files with 186 additions and 0 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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);