需求:后台「会员列表」要能看到会员真实的微信昵称与头像(原先微信注册会员存的是占位昵称『同城用户XXXX』+ 空头像,
列表只能显示字母头像)。
- A1 wx-login:新增可选入参 nickname/avatar(小程序用官方 open-type=chooseAvatar + <input type=nickname> 采集);
新用户直接落库、老用户带上新值时更新(不覆盖后台人工改过的资料)
- A6 POST /auth/avatar(新增,multipart file):复用 FastAdmin 上传库保存文件并写入 fa_user.avatar,
返回 {url 完整地址, avatar 相对路径},供小程序立即展示与后台列表 cdnurl 拼接
- A4 /auth/profile 支持 POST:可随时补填昵称/头像(空提交返回「没有需要更新的资料」)
- route.php 登记 api/v1/auth/avatar(沿用「新增接口必须登记」约定)
- tools/verify-api.mjs:新增 A6 缺文件、A4 空提交两项线上校验
339 行
13 KiB
PHP
339 行
13 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use think\Db;
|
||
|
||
/**
|
||
* 用户端 · 认证与登录(Module-AUTH)
|
||
* 路由:
|
||
* POST /api/v1/auth/wx-login A1 微信授权登录
|
||
* POST /api/v1/auth/bind-phone A2 手机号绑定(手动输入)
|
||
* POST /api/v1/auth/wx-phone A5 微信手机号快捷获取(getPhoneNumber)
|
||
* POST /api/v1/auth/avatar A6 头像上传(用户端)
|
||
* POST /api/v1/auth/switch-type A3 个人/商家身份切换
|
||
* GET|POST /api/v1/auth/profile A4 个人资料(GET 读取 / POST 更新昵称与头像)
|
||
*/
|
||
class Auth extends V1
|
||
{
|
||
/**
|
||
* A1 微信一键授权登录
|
||
* 入参 { code, nickname?, avatar? } → 出参 { token, refreshToken, userInfo }
|
||
* 规则:code 必填;首次登录自动建档并生成邀请码;
|
||
* v1.0.9 起支持携带微信昵称/头像(小程序用 `open-type="chooseAvatar"` + `<input type="nickname">` 采集),
|
||
* 新用户直接落库、老用户填了就更新,保证后台「会员列表」能看到真实微信昵称与头像。
|
||
*/
|
||
public function wxLogin()
|
||
{
|
||
$code = trim((string)$this->input('code', ''));
|
||
if (!$code) {
|
||
$this->fail('缺少微信授权 code');
|
||
}
|
||
|
||
$nickname = trim((string)$this->input('nickname', ''));
|
||
$avatar = trim((string)$this->input('avatar', ''));
|
||
if (mb_strlen($nickname) > 32) {
|
||
$nickname = mb_substr($nickname, 0, 32);
|
||
}
|
||
|
||
$openid = $this->resolveOpenid($code);
|
||
|
||
$ext = Db::name('fc_user_ext')->where('openid', $openid)->find();
|
||
$user = $ext ? Db::name('user')->where('id', $ext['user_id'])->find() : null;
|
||
|
||
if (!$user) {
|
||
// 首次登录:自动建号(有微信昵称/头像就落库,否则用占位昵称 + 字母头像)
|
||
$uid = Db::name('user')->insertGetId([
|
||
'group_id' => 1,
|
||
'username' => 'wx' . substr(md5($openid), 0, 10),
|
||
'nickname' => $nickname !== '' ? $nickname : '同城用户' . mt_rand(1000, 9999),
|
||
'password' => md5(mt_rand()),
|
||
'salt' => 'wx',
|
||
'email' => substr(md5($openid), 0, 12) . '@wx.local',
|
||
'mobile' => '',
|
||
'avatar' => $avatar,
|
||
'level' => 1,
|
||
'money' => 0,
|
||
'score' => 0,
|
||
'status' => 'normal',
|
||
'jointime' => time(),
|
||
'joinip' => $this->request->ip(),
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
Db::name('fc_user_ext')->insert([
|
||
'user_id' => $uid, 'openid' => $openid, 'user_type' => 1, 'realname_status' => 0,
|
||
'is_member' => 0, 'member_level' => '', 'member_expiretime' => null,
|
||
'invite_code' => strtoupper(substr(md5('fc' . $uid), 0, 6)), 'inviter_id' => 0,
|
||
'free_view_date' => date('Y-m-d'), 'free_view_used' => 0,
|
||
'createtime' => time(), 'updatetime' => time(),
|
||
]);
|
||
$user = Db::name('user')->where('id', $uid)->find();
|
||
} else {
|
||
// 老用户:仅在本次带上了新值且确实不同时更新(不覆盖运营在后台改过的资料)
|
||
$update = [];
|
||
if ($nickname !== '' && $nickname !== (string)$user['nickname']) {
|
||
$update['nickname'] = $nickname;
|
||
}
|
||
if ($avatar !== '' && $avatar !== (string)$user['avatar']) {
|
||
$update['avatar'] = $avatar;
|
||
}
|
||
if ($update) {
|
||
$update['updatetime'] = time();
|
||
Db::name('user')->where('id', $user['id'])->update($update);
|
||
$user = Db::name('user')->where('id', $user['id'])->find();
|
||
}
|
||
}
|
||
|
||
if ($user['status'] != 'normal') {
|
||
$this->fail('账号已被禁用,请联系客服');
|
||
}
|
||
|
||
$token = $this->issueToken($user['id']);
|
||
$this->ok([
|
||
'token' => $token,
|
||
'refreshToken' => md5($token . 'refresh'),
|
||
'userInfo' => $this->userInfo($user),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* A2 手机号授权绑定
|
||
* 入参 { phone } → 出参 { phone }
|
||
* 规则 BR-02:必须匹配 ^1\d{10}$
|
||
*/
|
||
public function bindPhone()
|
||
{
|
||
$user = $this->auth();
|
||
$phone = trim((string)$this->input('phone', ''));
|
||
if (!preg_match('/^1\d{10}$/', $phone)) {
|
||
$this->fail('手机号格式不正确');
|
||
}
|
||
Db::name('user')->where('id', $user['id'])->update([
|
||
'mobile' => $phone, 'updatetime' => time(),
|
||
]);
|
||
$this->ok(['phone' => $phone]);
|
||
}
|
||
|
||
/**
|
||
* A5 微信手机号快捷获取
|
||
* 入参 { code }(`<button open-type="getPhoneNumber">` 回调里的 `e.detail.code`)
|
||
* → 出参 { phone }
|
||
* 规则:用小程序 access_token 调 phonenumber.getPhoneNumber 换号并写入 fa_user.mobile;
|
||
* 未配置微信参数 / 微信侧失败(快捷验证未开通、code 失效、用户拒绝)时返回业务失败,
|
||
* 前端据此回落到「手动输入手机号」(A2 bindPhone),保证任何情况下都能拿到号码。
|
||
*/
|
||
public function wxPhone()
|
||
{
|
||
$user = $this->auth();
|
||
|
||
$code = trim((string)$this->input('code', ''));
|
||
if ($code === '') {
|
||
$this->fail('缺少手机号授权 code');
|
||
}
|
||
|
||
$appid = (string)$this->cfg('wx.appid', '');
|
||
$secret = (string)$this->cfg('wx.secret', '');
|
||
if ($appid === '' || $secret === '') {
|
||
$this->fail('未配置小程序 AppID/AppSecret,请改用手动输入');
|
||
}
|
||
|
||
$token = $this->wxAccessToken($appid, $secret);
|
||
if ($token === '') {
|
||
$this->fail('微信 access_token 获取失败,请改用手动输入');
|
||
}
|
||
|
||
$ctx = stream_context_create([
|
||
'http' => [
|
||
'method' => 'POST',
|
||
'header' => "Content-Type: application/json\r\n",
|
||
'content' => json_encode(['code' => $code]),
|
||
'timeout' => 10,
|
||
'ignore_errors' => true,
|
||
],
|
||
]);
|
||
$res = @json_decode(@file_get_contents(
|
||
'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' . $token,
|
||
false,
|
||
$ctx
|
||
), true);
|
||
|
||
$phone = '';
|
||
if (!empty($res['phone_info']['phoneNumber'])) {
|
||
$phone = (string)$res['phone_info']['phoneNumber'];
|
||
} elseif (!empty($res['phone_info']['purePhoneNumber'])) {
|
||
$phone = (string)$res['phone_info']['purePhoneNumber'];
|
||
}
|
||
if (!preg_match('/^1\d{10}$/', $phone)) {
|
||
// 常见原因:手机号快捷验证未开通/额度用尽、code 已失效、用户拒绝授权
|
||
$this->fail('微信手机号获取失败,请改用手动输入');
|
||
}
|
||
|
||
Db::name('user')->where('id', $user['id'])->update([
|
||
'mobile' => $phone, 'updatetime' => time(),
|
||
]);
|
||
|
||
$this->ok(['phone' => $phone]);
|
||
}
|
||
|
||
/**
|
||
* 小程序 access_token(带缓存,提前 5 分钟失效)
|
||
* 缓存写入 fc_config 的 wx.accessToken / wx.accessTokenExpire(仅服务端读取,不下发前端)
|
||
* @param string $appid
|
||
* @param string $secret
|
||
* @return string 失败返回空串
|
||
*/
|
||
protected function wxAccessToken($appid, $secret)
|
||
{
|
||
$now = time();
|
||
$cached = (string)$this->cfg('wx.accessToken', '');
|
||
$expire = (int)$this->cfg('wx.accessTokenExpire', 0);
|
||
if ($cached !== '' && $expire > $now + 300) {
|
||
return $cached;
|
||
}
|
||
|
||
$url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential'
|
||
. '&appid=' . $appid . '&secret=' . $secret;
|
||
$res = @json_decode(@file_get_contents($url), true);
|
||
if (empty($res['access_token'])) {
|
||
return '';
|
||
}
|
||
|
||
$this->setCfg('wx.accessToken', (string)$res['access_token']);
|
||
$this->setCfg('wx.accessTokenExpire', $now + (int)(!empty($res['expires_in']) ? $res['expires_in'] : 7200));
|
||
|
||
return (string)$res['access_token'];
|
||
}
|
||
|
||
/**
|
||
* A3 用户类型切换
|
||
* 入参 { userType: 1|2, license? } → 出参 { userType, needAudit }
|
||
* 规则 BR-01:切换商家必须提交营业执照,提交后进入待审核
|
||
*/
|
||
public function switchType()
|
||
{
|
||
$user = $this->auth();
|
||
$userType = (int)$this->input('userType', 0);
|
||
if (!in_array($userType, [1, 2], true)) {
|
||
$this->fail('用户类型不合法');
|
||
}
|
||
|
||
if ($userType === 2) {
|
||
$license = trim((string)$this->input('license', ''));
|
||
if (!$license) {
|
||
$this->fail('商家用户需上传营业执照完成资质认证');
|
||
}
|
||
$name = trim((string)$this->input('merchantName', ''));
|
||
$exists = Db::name('fc_merchant')->where('user_id', $user['id'])->find();
|
||
$data = [
|
||
'user_id' => $user['id'],
|
||
'name' => $name !== '' ? $name : (string)$user['nickname'],
|
||
'license' => $license,
|
||
'audit_status' => 0,
|
||
'shop_status' => 'pending',
|
||
'updatetime' => time(),
|
||
];
|
||
if ($exists) {
|
||
Db::name('fc_merchant')->where('id', $exists['id'])->update($data);
|
||
} else {
|
||
$data['createtime'] = time();
|
||
Db::name('fc_merchant')->insert($data);
|
||
}
|
||
}
|
||
|
||
Db::name('fc_user_ext')->where('user_id', $user['id'])->update([
|
||
'user_type' => $userType, 'updatetime' => time(),
|
||
]);
|
||
|
||
$this->ok(['userType' => $userType, 'needAudit' => $userType === 2]);
|
||
}
|
||
|
||
/**
|
||
* A6 头像上传(用户端)
|
||
* POST /api/v1/auth/avatar multipart: file
|
||
* → { url, avatar }
|
||
* 说明:复用 FastAdmin 上传库(与后台上传同一套类型/大小校验与存储);
|
||
* `avatar` 返回相对路径(与 fa_user.avatar 存储一致,后台列表用 cdnurl 拼完整地址),
|
||
* `url` 为完整地址,供小程序立即展示。
|
||
*/
|
||
public function uploadAvatar()
|
||
{
|
||
$user = $this->auth();
|
||
|
||
$file = $this->request->file('file');
|
||
if (!$file) {
|
||
$this->fail('未接收到上传文件');
|
||
}
|
||
try {
|
||
$upload = new \app\common\library\Upload($file);
|
||
$attachment = $upload->upload();
|
||
} catch (\Exception $e) {
|
||
$this->fail($e->getMessage());
|
||
}
|
||
|
||
$path = (string)$attachment->url;
|
||
Db::name('user')->where('id', $user['id'])->update([
|
||
'avatar' => $path,
|
||
'updatetime' => time(),
|
||
]);
|
||
|
||
$this->ok(['url' => cdnurl($path, true), 'avatar' => $path]);
|
||
}
|
||
|
||
/**
|
||
* A4 个人资料
|
||
* GET → userInfo(含会员状态、余额)
|
||
* POST { nickname?, avatar? } → 更新后的 userInfo(微信昵称/头像可在登录后随时补填)
|
||
*/
|
||
public function profile()
|
||
{
|
||
$user = $this->auth();
|
||
|
||
if ($this->request->isPost()) {
|
||
$nickname = trim((string)$this->input('nickname', ''));
|
||
$avatar = trim((string)$this->input('avatar', ''));
|
||
$update = [];
|
||
if ($nickname !== '') {
|
||
if (mb_strlen($nickname) > 32) {
|
||
$nickname = mb_substr($nickname, 0, 32);
|
||
}
|
||
$update['nickname'] = $nickname;
|
||
}
|
||
if ($avatar !== '') {
|
||
$update['avatar'] = $avatar;
|
||
}
|
||
if (!$update) {
|
||
$this->fail('没有需要更新的资料');
|
||
}
|
||
$update['updatetime'] = time();
|
||
Db::name('user')->where('id', $user['id'])->update($update);
|
||
$user = Db::name('user')->where('id', $user['id'])->find();
|
||
}
|
||
|
||
$this->ok($this->userInfo($user));
|
||
}
|
||
|
||
/**
|
||
* code -> openid
|
||
* 已配置微信参数时走 jscode2session;未配置(本地/联调)时用确定性派生,便于前后端联调。
|
||
*/
|
||
protected function resolveOpenid($code)
|
||
{
|
||
$appid = (string)$this->cfg('wx.appid', '');
|
||
$secret = (string)$this->cfg('wx.secret', '');
|
||
if ($appid && $secret) {
|
||
$url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' . $appid
|
||
. '&secret=' . $secret . '&js_code=' . urlencode($code) . '&grant_type=authorization_code';
|
||
$res = @json_decode(@file_get_contents($url), true);
|
||
if (!empty($res['openid'])) {
|
||
return $res['openid'];
|
||
}
|
||
}
|
||
// 未配置微信参数:若 code 本身即为 openid 标识则直接采用,否则派生
|
||
if (strpos($code, 'openid_') === 0) {
|
||
return $code;
|
||
}
|
||
return 'openid_' . md5($code);
|
||
}
|
||
}
|