chore: 移除NFC/蓝牙相关支持,更新设备在线统计,新增功能优化

1.  移除NFC和蓝牙相关依赖、权限及功能代码,精简传输链路
2.  重构设备在线统计逻辑,使用后端7天活跃字段替代本地计算
3.  更新应用名称、权限说明和协议文档
4.  新增消息转发、缓存管理、医疗免责提示功能
5.  优化运势模块和字体管理文案,修复构建日志问题
This commit is contained in:
Developer
2026-06-06 06:12:09 +08:00
parent e0329ab103
commit 214a0684d0
122 changed files with 5849 additions and 3710 deletions

View File

@@ -0,0 +1,709 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Exception;
/**
* OAuth2.0 社交登录
*
* 支持平台: apple, google, github
* 流程: 客户端获取授权码 → 服务端换取access_token → 获取用户信息 → 创建/关联账号
*
* 创建时间: 2026-06-05
* 更新时间: 2026-06-05
* 名称: Oauth
* 作用: OAuth社交登录接口支持Apple/Google/GitHub三方登录、绑定、解绑
* 上次更新: 新增OAuth社交登录接口
*/
class Oauth extends Api
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
/** OAuth平台远程地址配置 */
private $oauthConfig = [
'apple' => [
'verify_url' => 'https://appleid.apple.com/auth/token',
'revoke_url' => 'https://appleid.apple.com/auth/revoke',
],
'google' => [
'token_url' => 'https://oauth2.googleapis.com/token',
'userinfo_url' => 'https://www.googleapis.com/oauth2/v3/userinfo',
],
'github' => [
'token_url' => 'https://github.com/login/oauth/access_token',
'userinfo_url' => 'https://api.github.com/user',
],
];
/** 频率限制配置 */
private static $rateLimits = [
'login' => ['max' => 30, 'window' => 300],
'bind' => ['max' => 20, 'window' => 3600],
'unbind' => ['max' => 20, 'window' => 3600],
];
/** 支持的平台列表 */
private static $supportedPlatforms = ['apple', 'google', 'github'];
// ==================== 公开接口 ====================
/**
* 获取OAuth配置
* GET /api/oauth/config?platform=apple
*/
public function config()
{
$platform = $this->request->param('platform', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$config = $this->getOAuthConfig($platform);
if (!$config) {
$this->error('平台未配置', null, ['platform' => $platform, 'configured' => false]);
}
$this->success('', [
'platform' => $platform,
'configured' => true,
'client_id' => $config['client_id'] ?? '',
'redirect_uri' => $config['redirect_uri'] ?? '',
'authorize_url' => $this->getAuthorizeUrl($platform, $config),
]);
}
/**
* 社交登录
* POST /api/oauth/login
*
* 参数:
* - platform: apple/google/github
* - code: 授权码google/github使用
* - id_token: Apple ID Tokenapple使用
* - device_name: 设备名称(可选)
* - device_model: 设备型号(可选)
* - platform_type: 登录平台ios/android/web可选
* - device_id: 设备唯一标识(可选)
*/
public function login()
{
$platform = $this->request->post('platform', '', 'trim');
$code = $this->request->post('code', '', 'trim');
$idToken = $this->request->post('id_token', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
// 频率限制
$this->checkRateLimit('login');
// 获取第三方用户信息
try {
$oauthUser = $this->getOAuthUserInfo($platform, $code, $idToken);
} catch (Exception $e) {
$this->error('OAuth验证失败: ' . $e->getMessage());
}
if (!$oauthUser || empty($oauthUser['openid'])) {
$this->error('获取用户信息失败');
}
// 查找或创建用户
$user = $this->findOrCreateUser($platform, $oauthUser);
if (!$user) {
$this->error('登录失败');
}
// 生成Token
$token = $this->generateToken($user);
// 记录设备信息
$this->recordDeviceInfo($user);
$this->success('登录成功', [
'userinfo' => [
'id' => $user['id'],
'username' => $user['username'],
'nickname' => $user['nickname'],
'email' => $user['email'],
'avatar' => $user['avatar'],
],
'token' => $token,
'is_new_user' => $oauthUser['is_new'] ?? false,
'bind_platform' => $platform,
]);
}
/**
* 绑定社交账号(已登录用户)
* POST /api/oauth/bind
*/
public function bind()
{
$this->checkLogin();
$platform = $this->request->post('platform', '', 'trim');
$code = $this->request->post('code', '', 'trim');
$idToken = $this->request->post('id_token', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$this->checkRateLimit('bind');
try {
$oauthUser = $this->getOAuthUserInfo($platform, $code, $idToken);
} catch (Exception $e) {
$this->error('OAuth验证失败: ' . $e->getMessage());
}
// 检查是否已被其他用户绑定
$existing = Db::name('user_oauth')
->where('platform', $platform)
->where('openid', $oauthUser['openid'])
->find();
if ($existing) {
if ($existing['user_id'] == $this->auth->id) {
$this->error('已绑定该平台');
}
$this->error('该账号已被其他用户绑定');
}
// 绑定
Db::name('user_oauth')->insert([
'user_id' => $this->auth->id,
'platform' => $platform,
'openid' => $oauthUser['openid'],
'unionid' => $oauthUser['unionid'] ?? '',
'nickname' => $oauthUser['nickname'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('绑定成功', [
'platform' => $platform,
'nickname' => $oauthUser['nickname'] ?? '',
]);
}
/**
* 解绑社交账号
* POST /api/oauth/unbind
*/
public function unbind()
{
$this->checkLogin();
$platform = $this->request->post('platform', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$this->checkRateLimit('unbind');
$count = Db::name('user_oauth')
->where('user_id', $this->auth->id)
->where('platform', $platform)
->delete();
if ($count) {
$this->success('解绑成功');
}
$this->error('未绑定该平台');
}
/**
* 获取已绑定的社交账号列表
* GET /api/oauth/bound
*/
public function bound()
{
$this->checkLogin();
$list = Db::name('user_oauth')
->where('user_id', $this->auth->id)
->field('platform,openid,nickname,avatar,createtime')
->select();
$this->success('', ['bindings' => $list]);
}
/**
* 安装OAuth数据表
* GET /api/oauth/install
*/
public function install()
{
$sql = "CREATE TABLE IF NOT EXISTS `tool_user_oauth` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
`platform` varchar(30) NOT NULL DEFAULT '' COMMENT '平台(apple/google/github)',
`openid` varchar(128) NOT NULL DEFAULT '' COMMENT '平台用户ID',
`unionid` varchar(128) NOT NULL DEFAULT '' COMMENT '联合ID',
`nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '昵称',
`avatar` varchar(500) NOT NULL DEFAULT '' COMMENT '头像',
`access_token` text COMMENT '访问令牌',
`refresh_token` text COMMENT '刷新令牌',
`expires_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '令牌过期时间',
`createtime` int(11) unsigned NOT NULL DEFAULT 0,
`updatetime` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_platform_openid` (`platform`, `openid`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户OAuth绑定表';";
try {
Db::execute($sql);
$this->success('安装成功', [
'table' => 'tool_user_oauth',
'created' => true,
]);
} catch (Exception $e) {
$this->error('安装失败: ' . $e->getMessage());
}
}
// ==================== 私有方法 ====================
/**
* 获取OAuth配置
* 优先从数据库读取,回退到配置文件
*/
private function getOAuthConfig($platform)
{
// 从数据库 tool_oauth_config 读取
try {
$config = Db::name('oauth_config')
->where('platform', $platform)
->where('status', 1)
->find();
if ($config) {
return json_decode($config['config'], true);
}
} catch (Exception $e) {
// 表不存在时忽略,回退到配置文件
}
// 回退到配置文件
$fileConfig = get_addon_config('third');
return $fileConfig[$platform] ?? null;
}
/**
* 获取授权URL
*/
private function getAuthorizeUrl($platform, $config)
{
$clientId = $config['client_id'] ?? '';
$redirectUri = $config['redirect_uri'] ?? '';
$state = md5(uniqid());
switch ($platform) {
case 'apple':
return "https://appleid.apple.com/auth/authorize?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&response_type=code&scope=name%20email&state={$state}";
case 'google':
return "https://accounts.google.com/o/oauth2/v2/auth?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&response_type=code&scope=openid%20email%20profile&state={$state}";
case 'github':
return "https://github.com/login/oauth/authorize?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&scope=user:email&state={$state}";
default:
return '';
}
}
/**
* 获取OAuth用户信息分发到各平台验证方法
*/
private function getOAuthUserInfo($platform, $code, $idToken)
{
switch ($platform) {
case 'apple':
return $this->verifyApple($idToken, $code);
case 'google':
return $this->verifyGoogle($code);
case 'github':
return $this->verifyGithub($code);
default:
throw new Exception('不支持的平台');
}
}
/**
* 验证Apple ID Token
* Apple返回的id_token是JWT格式解析payload获取用户信息
*/
private function verifyApple($idToken, $code)
{
if (empty($idToken)) {
throw new Exception('id_token不能为空');
}
// 解码JWTApple ID Token是JWT格式
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
throw new Exception('无效的id_token格式');
}
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
if (!$payload) {
throw new Exception('无法解析id_token');
}
// 验证issuer
if (($payload['iss'] ?? '') !== 'https://appleid.apple.com') {
throw new Exception('无效的issuer');
}
return [
'openid' => $payload['sub'] ?? '',
'email' => $payload['email'] ?? '',
'nickname' => '',
'avatar' => '',
'access_token' => $code,
'refresh_token' => '',
'expires_at' => $payload['exp'] ?? 0,
];
}
/**
* 验证Google授权码
* 用授权码换取access_token再获取用户信息
*/
private function verifyGoogle($code)
{
if (empty($code)) {
throw new Exception('授权码不能为空');
}
$config = $this->getOAuthConfig('google');
if (!$config) {
throw new Exception('Google OAuth未配置');
}
// 用授权码换取access_token
$response = $this->httpPost($this->oauthConfig['google']['token_url'], [
'code' => $code,
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'redirect_uri' => $config['redirect_uri'],
'grant_type' => 'authorization_code',
]);
$tokenData = json_decode($response, true);
if (empty($tokenData['access_token'])) {
throw new Exception('获取Google access_token失败');
}
// 获取用户信息
$userInfo = $this->httpGet($this->oauthConfig['google']['userinfo_url'], [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$userData = json_decode($userInfo, true);
if (empty($userData['sub'])) {
throw new Exception('获取Google用户信息失败');
}
return [
'openid' => 'google_' . $userData['sub'],
'unionid' => $userData['sub'] ?? '',
'email' => $userData['email'] ?? '',
'nickname' => $userData['name'] ?? '',
'avatar' => $userData['picture'] ?? '',
'access_token' => $tokenData['access_token'],
'refresh_token' => $tokenData['refresh_token'] ?? '',
'expires_at' => time() + ($tokenData['expires_in'] ?? 3600),
];
}
/**
* 验证GitHub授权码
* 用授权码换取access_token再获取用户信息
*/
private function verifyGithub($code)
{
if (empty($code)) {
throw new Exception('授权码不能为空');
}
$config = $this->getOAuthConfig('oauth_github');
if (!$config) {
// 回退尝试 github 配置
$config = $this->getOAuthConfig('github');
}
if (!$config) {
throw new Exception('GitHub OAuth未配置');
}
// 用授权码换取access_token
$response = $this->httpPost($this->oauthConfig['github']['token_url'], [
'code' => $code,
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'redirect_uri' => $config['redirect_uri'],
], ['Accept: application/json']);
$tokenData = json_decode($response, true);
if (empty($tokenData['access_token'])) {
throw new Exception('获取GitHub access_token失败');
}
// 获取用户信息
$userInfo = $this->httpGet($this->oauthConfig['github']['userinfo_url'], [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$userData = json_decode($userInfo, true);
if (empty($userData['id'])) {
throw new Exception('获取GitHub用户信息失败');
}
// 获取邮箱GitHub可能不返回主邮箱
$email = $userData['email'] ?? '';
if (empty($email)) {
$emails = $this->httpGet('https://api.github.com/user/emails', [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$emailList = json_decode($emails, true);
if (is_array($emailList)) {
foreach ($emailList as $e) {
if (($e['primary'] ?? false) && ($e['verified'] ?? false)) {
$email = $e['email'];
break;
}
}
}
}
return [
'openid' => 'github_' . $userData['id'],
'unionid' => (string)$userData['id'],
'email' => $email,
'nickname' => $userData['login'] ?? '',
'avatar' => $userData['avatar_url'] ?? '',
'access_token' => $tokenData['access_token'],
'refresh_token' => '',
'expires_at' => 0,
];
}
/**
* 查找或创建用户
* 1. 查找已有绑定 → 2. 通过邮箱查找 → 3. 创建新用户
*/
private function findOrCreateUser($platform, $oauthUser)
{
// 1. 查找已有绑定
$binding = Db::name('user_oauth')
->where('platform', $platform)
->where('openid', $oauthUser['openid'])
->find();
if ($binding) {
$user = Db::name('user')->where('id', $binding['user_id'])->find();
if ($user) {
// 更新token
Db::name('user_oauth')->where('id', $binding['id'])->update([
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'updatetime' => time(),
]);
$oauthUser['is_new'] = false;
return $user;
}
}
// 2. 通过邮箱查找已有用户
$user = null;
if (!empty($oauthUser['email'])) {
$user = Db::name('user')->where('email', $oauthUser['email'])->find();
}
// 3. 创建新用户
if (!$user) {
$username = $platform . '_' . substr($oauthUser['openid'], -8);
// 确保用户名唯一
$i = 1;
$baseUsername = $username;
while (Db::name('user')->where('username', $username)->find()) {
$username = $baseUsername . '_' . $i++;
}
$userId = Db::name('user')->insertGetId([
'username' => $username,
'nickname' => $oauthUser['nickname'] ?: $username,
'email' => $oauthUser['email'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'password' => md5(md5(uniqid())),
'salt' => '',
'status' => 'normal',
'verification' => json_encode(['email' => !empty($oauthUser['email']) ? 1 : 0]),
'createtime' => time(),
'updatetime' => time(),
]);
$user = Db::name('user')->where('id', $userId)->find();
$oauthUser['is_new'] = true;
} else {
$oauthUser['is_new'] = false;
}
// 4. 创建绑定
Db::name('user_oauth')->insert([
'user_id' => $user['id'],
'platform' => $platform,
'openid' => $oauthUser['openid'],
'unionid' => $oauthUser['unionid'] ?? '',
'nickname' => $oauthUser['nickname'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'createtime' => time(),
'updatetime' => time(),
]);
return $user;
}
/**
* 生成Token
*/
private function generateToken($user)
{
$token = \fast\Random::uuid();
Db::name('user_token')->insert([
'user_id' => $user['id'],
'token' => $token,
'createtime' => time(),
'expiretime' => time() + 86400 * 30,
]);
return $token;
}
/**
* 记录设备信息
*/
private function recordDeviceInfo($user)
{
$deviceName = $this->request->post('device_name', '', 'trim');
$deviceModel = $this->request->post('device_model', '', 'trim');
$platformType = $this->request->post('platform_type', '', 'trim');
$deviceId = $this->request->post('device_id', '', 'trim');
$ipCity = $this->request->post('ip_city', '', 'trim');
$ipRange = $this->request->post('ip_range', '', 'trim');
if ($deviceId) {
$existing = Db::name('user_device')
->where('user_id', $user['id'])
->where('device_id', $deviceId)
->find();
$data = [
'device_name' => $deviceName ?: $deviceModel,
'device_model' => $deviceModel,
'platform' => $platformType,
'app_name' => '闲言工具箱',
'ip' => request()->ip(),
'ip_city' => $ipCity,
'ip_range' => $ipRange,
'last_active_time' => time(),
'is_online' => 1,
'updatetime' => time(),
];
if ($existing) {
Db::name('user_device')->where('id', $existing['id'])->update($data);
} else {
$data['user_id'] = $user['id'];
$data['device_id'] = $deviceId;
$data['createtime'] = time();
Db::name('user_device')->insert($data);
}
}
// 更新用户在线状态
Db::name('user')->where('id', $user['id'])->update([
'is_online' => 1,
'last_active_time' => time(),
'logintime' => time(),
'updatetime' => time(),
]);
}
/**
* 频率限制检查
*/
private function checkRateLimit($action)
{
if (!isset(self::$rateLimits[$action])) {
return true;
}
$config = self::$rateLimits[$action];
$key = 'rate_oauth_' . $action . '_' . md5($this->request->ip());
$count = cache($key) ?: 0;
if ($count >= $config['max']) {
$this->error('请求过于频繁,请稍后再试', null, ['retry_after' => $config['window']]);
}
cache($key, $count + 1, $config['window']);
return true;
}
/**
* 检查用户是否已登录
*/
private function checkLogin()
{
if (!$this->auth->id) {
$this->error('请先登录', null, 401);
}
}
/**
* HTTP GET 请求
*/
private function httpGet($url, $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => array_merge(['User-Agent: XianYan/1.0'], $headers),
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
/**
* HTTP POST 请求
*/
private function httpPost($url, $data = [], $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => array_merge(['User-Agent: XianYan/1.0', 'Accept: application/json'], $headers),
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
}

View File

@@ -1322,12 +1322,22 @@ class UserCenter extends Api
->order('last_active_time', 'desc')
->field('id,device_name,device_model,platform,app_name,ip,ip_city,ip_range,last_active_time,is_online,createtime')
->select();
$sevenDaysAgo = time() - 7 * 86400;
foreach ($devices as &$d) {
$d['last_active_text'] = $d['last_active_time'] ? date('Y-m-d H:i:s', $d['last_active_time']) : '-';
$d['createtime_text'] = $d['createtime'] ? date('Y-m-d H:i:s', $d['createtime']) : '-';
// 7天内是否活跃在线
$d['is_active_recently'] = ($d['last_active_time'] >= $sevenDaysAgo && $d['is_online'] == 1) ? 1 : 0;
}
unset($d);
$this->success('', ['devices' => $devices]);
// 统计活跃在线设备数
$activeOnlineCount = 0;
foreach ($devices as $dev) {
if ($dev['is_active_recently'] == 1) {
$activeOnlineCount++;
}
}
$this->success('', ['devices' => $devices, 'active_online_count' => $activeOnlineCount]);
} elseif ($action === 'remove') {
$deviceId = $this->request->param('device_id/d', 0);
if (!$deviceId) {

View File

@@ -66,6 +66,16 @@ Route::rule([
'api/user_security/cancelDeletion' => 'api/UserSecurity/cancelDeletion',
]);
// OAuth社交登录
Route::rule([
'api/oauth/config' => 'api/Oauth/config',
'api/oauth/login' => 'api/Oauth/login',
'api/oauth/bind' => 'api/Oauth/bind',
'api/oauth/unbind' => 'api/Oauth/unbind',
'api/oauth/bound' => 'api/Oauth/bound',
'api/oauth/install' => 'api/Oauth/install',
]);
// 用户中心接口
Route::rule([
'api/user_center/index' => 'api/UserCenter/index',

View File

@@ -0,0 +1,221 @@
# 闲言APP — OAuth社交登录 API 接口文档
> 基础URL: `https://tools.wktyl.com`
> 版本: v1.0.0 | 创建时间: 2026-06-05
> 支持平台: Apple / Google / GitHub
> 关联文档: API_USER_SECURITY_DOC.md
---
## 一、概述
OAuth社交登录允许用户通过 Apple、Google、GitHub 账号快速登录闲言APP无需手动注册。
**核心流程:**
1. 客户端调用 `/api/oauth/config` 获取授权URL
2. 用户在第三方平台完成授权,获取授权码(id_token)
3. 客户端调用 `/api/oauth/login` 提交授权码
4. 服务端验证授权码,创建/关联用户返回Token
**与闲言自有注册的关系:**
- OAuth登录是独立通道不影响现有用户名+密码注册
- OAuth登录后自动创建闲言账号邮箱相同的会自动关联
- 已登录用户可通过 `/api/oauth/bind` 绑定社交账号
---
## 二、接口概览
| 接口 | 方法 | 路径 | 需登录 | 说明 |
|------|------|------|--------|------|
| 获取OAuth配置 | GET | `/api/oauth/config` | ❌ | 获取授权URL和client_id |
| 社交登录 | POST | `/api/oauth/login` | ❌ | 授权码登录 |
| 绑定社交账号 | POST | `/api/oauth/bind` | ✅ | 已登录用户绑定 |
| 解绑社交账号 | POST | `/api/oauth/unbind` | ✅ | 已登录用户解绑 |
| 已绑定列表 | GET | `/api/oauth/bound` | ✅ | 查询已绑定平台 |
| 安装数据表 | GET | `/api/oauth/install` | ❌ | 初始化 |
---
## 三、接口详情
### 3.1 获取OAuth配置
**GET** `/api/oauth/config?platform=apple`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| platform | string | ✅ | 平台: apple/google/github |
**响应示例:**
```json
{
"code": 1,
"msg": "",
"data": {
"platform": "github",
"configured": true,
"client_id": "Ov23li...",
"redirect_uri": "https://tools.wktyl.com/oauth/callback",
"authorize_url": "https://github.com/login/oauth/authorize?client_id=Ov23li...&redirect_uri=...&scope=user:email&state=..."
}
}
```
### 3.2 社交登录
**POST** `/api/oauth/login`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| platform | string | ✅ | 平台: apple/google/github |
| code | string | 条件 | 授权码google/github必填 |
| id_token | string | 条件 | Apple ID Tokenapple必填 |
| device_name | string | ❌ | 设备名称 |
| device_model | string | ❌ | 设备型号 |
| platform_type | string | ❌ | 登录平台(ios/android/web) |
| device_id | string | ❌ | 设备唯一标识 |
| ip_city | string | ❌ | IP归属地 |
| ip_range | string | ❌ | IP段范围 |
**Apple登录流程**
1. 客户端使用 `sign_in_with_apple` SDK 获取 `id_token``authorization_code`
2. POST `/api/oauth/login` 提交 `platform=apple&id_token=xxx&code=xxx`
**Google登录流程**
1. 客户端使用 `google_sign_in` SDK 获取 `serverAuthCode`
2. POST `/api/oauth/login` 提交 `platform=google&code=xxx`
**GitHub登录流程**
1. 客户端打开浏览器授权页面
2. 用户授权后回调获取 `code`
3. POST `/api/oauth/login` 提交 `platform=github&code=xxx`
**成功响应:**
```json
{
"code": 1,
"msg": "登录成功",
"data": {
"userinfo": {
"id": 42,
"username": "github_abc12345",
"nickname": "octocat",
"email": "user@example.com",
"avatar": "https://avatars.githubusercontent.com/u/..."
},
"token": "xxxxxxxxxxxx",
"is_new_user": true,
"bind_platform": "github"
}
}
```
### 3.3 绑定社交账号
**POST** `/api/oauth/bind`
需登录。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| platform | string | ✅ | 平台: apple/google/github |
| code | string | 条件 | 授权码 |
| id_token | string | 条件 | Apple ID Token |
### 3.4 解绑社交账号
**POST** `/api/oauth/unbind`
需登录。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| platform | string | ✅ | 平台: apple/google/github |
### 3.5 已绑定列表
**GET** `/api/oauth/bound`
需登录。
**响应示例:**
```json
{
"code": 1,
"msg": "",
"data": {
"bindings": [
{
"platform": "github",
"openid": "github_12345",
"nickname": "octocat",
"avatar": "https://...",
"createtime": 1717200000
}
]
}
}
```
### 3.6 安装数据表
**GET** `/api/oauth/install`
无需登录。创建 `tool_user_oauth` 表。
---
## 四、数据表设计
### tool_user_oauth
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int(11) unsigned | 主键自增 |
| user_id | int(11) unsigned | 用户ID |
| platform | varchar(30) | 平台(apple/google/github) |
| openid | varchar(128) | 平台用户ID |
| unionid | varchar(128) | 联合ID |
| nickname | varchar(100) | 昵称 |
| avatar | varchar(500) | 头像 |
| access_token | text | 访问令牌 |
| refresh_token | text | 刷新令牌 |
| expires_at | int(11) unsigned | 令牌过期时间 |
| createtime | int(11) unsigned | 创建时间 |
| updatetime | int(11) unsigned | 更新时间 |
**索引:** uk_platform_openid(platform, openid), idx_user_id(user_id)
---
## 五、错误码
| code | msg | 说明 |
|------|-----|------|
| 0 | 不支持的平台 | platform参数无效 |
| 0 | 平台未配置 | OAuth配置缺失 |
| 0 | OAuth验证失败 | 授权码/id_token无效 |
| 0 | 该账号已被其他用户绑定 | 绑定冲突 |
| 0 | 已绑定该平台 | 重复绑定 |
| 0 | 请求过于频繁 | 频率限制(30次/5分钟) |
---
## 六、频率限制
| 接口 | 上限 | 时间窗口(秒) |
|------|------|-------------|
| login | 30次 | 300 |
| bind | 20次 | 3600 |
| unbind | 20次 | 3600 |
---
## 七、安全说明
- Apple: 验证JWT的issuer和签名不依赖客户端解析
- Google: 服务端用授权码换取access_token客户端无法伪造
- GitHub: 服务端用授权码换取access_token安全可靠
- 同一openid只能绑定一个闲言账号
- 邮箱相同的OAuth用户会自动关联已有账号

View File

@@ -806,7 +806,7 @@ curl -X POST https://tools.wktyl.com/api/user_center/interaction \
| action | 说明 | 必填参数 | 响应 |
|--------|------|----------|------|
| list | 📱 设备列表 | - | {devices: [{...}]} |
| list | 📱 设备列表 | - | {devices: [{...}], active_online_count: int} |
| remove | 🗑️ 删除设备 | device_id | 删除成功 |
| offline | ⬇️ 下线设备 | device_id | 下线成功 |
| offline_all | ⬇️⬇️ 下线所有设备 | - | 全部下线成功 |
@@ -829,6 +829,7 @@ curl -X POST https://tools.wktyl.com/api/user_center/interaction \
| createtime | int | 首次登录时间戳 |
| last_active_text | string | 最后活跃时间(可读格式, 如"5分钟前") |
| createtime_text | string | 首次登录时间(可读格式, 如"2026-05-01 10:30") |
| is_active_recently | int | 🆕 7天内是否活跃在线(0/1, 后端计算) |
**请求示例:**
```bash
@@ -882,7 +883,8 @@ curl -X POST https://tools.wktyl.com/api/user_center/devices \
"is_online": 1,
"createtime": 1714588800,
"last_active_text": "5分钟前",
"createtime_text": "2026-05-01 10:30"
"createtime_text": "2026-05-01 10:30",
"is_active_recently": 1
},
{
"id": 2,
@@ -897,9 +899,11 @@ curl -X POST https://tools.wktyl.com/api/user_center/devices \
"is_online": 0,
"createtime": 1712006400,
"last_active_text": "2小时前",
"createtime_text": "2026-04-02 08:00"
"createtime_text": "2026-04-02 08:00",
"is_active_recently": 0
}
]
],
"active_online_count": 1
}
}
```

View File

@@ -261,7 +261,7 @@
<div class="content-card">
<div id="content-zh" class="lang-content">
<p><strong>闲言APP</strong> 软件介绍</p>
<p>更新日期2026年5月20</p>
<p>更新日期2026年6月5</p>
<h2>一、关于闲言</h2>
<p><strong>闲言APP</strong>是一款优雅的文字阅读与卡片创作应用,由<strong>弥勒市朋普镇微风暴网络科技工作室</strong>开发运营。我们致力于让文字阅读更纯粹,用文字点亮生活的每一刻。</p>
<h2>二、核心功能</h2>
@@ -298,8 +298,8 @@
<h3>2.5 文件传输助手</h3>
<ul>
<li>局域网高速传输</li>
<li>蓝牙传输</li>
<li>USB OTG有线传输</li>
<li>二维码配对传输</li>
<li>跨平台支持</li>
<li><span class="highlight">端到端加密</span></li>
</ul>
@@ -367,8 +367,8 @@
</div>
<div id="content-en" class="lang-content" style="display:none;">
<p><strong>Xianyan APP</strong> — About Xianyan</p>
<p>Version: V6.5</p>
<p>Updated: May 20, 2026</p>
<p>Version: V6.6</p>
<p>Updated: June 5, 2026</p>
<h2>I. App Overview</h2>
<p><strong>Xianyan</strong> is an elegant sentence reading and card creation app that illuminates every moment of your life with words.</p>
<h2>II. Core Features</h2>

View File

@@ -261,9 +261,9 @@
<div class="content-card">
<div id="content-zh" class="lang-content">
<p><strong>闲言APP</strong> 软件权限使用说明</p>
<p>版本号V6.5</p>
<p>更新日期2026年5月20</p>
<p>生效日期2026年5月21</p>
<p>版本号V6.6</p>
<p>更新日期2026年6月5</p>
<p>生效日期2026年6月6</p>
<p>为保障<strong>闲言APP</strong>各项功能的正常使用,我们可能需要向您申请以下设备权限。我们严格遵循<span class="highlight">最小必要原则</span>,仅申请实现功能所必需的权限,并明确告知每项权限的使用目的。</p>
<h2>一、相机权限</h2>
<h3>1.1 权限说明:用于拍照和扫描功能</h3>
@@ -310,56 +310,48 @@
<li>您可随时在系统设置中关闭此权限</li>
<li>关闭后天气功能需手动选择城市</li>
</ul>
<h2>五、蓝牙权限</h2>
<h3>5.1 权限说明:用于蓝牙设备发现和文件传输</h3>
<h2>五、附近设备权限</h2>
<h3>5.1 权限说明:用于局域网设备发现和通信</h3>
<h3>5.2 使用场景:</h3>
<ul>
<li>文件传输助手中的蓝牙配对传输</li>
<li>近距离设备发现</li>
</ul>
<h3>5.3 权限控制:您可随时在系统设置中关闭此权限,关闭后蓝牙传输功能将不可用</h3>
<h2>六、附近设备权限</h2>
<h3>6.1 权限说明:用于局域网设备发现和通信</h3>
<h3>6.2 使用场景:</h3>
<ul>
<li>文件传输助手中的局域网设备发现</li>
<li>局域网文件传输</li>
</ul>
<h3>6.3 权限控制:您可随时在系统设置中关闭此权限,关闭后局域网传输功能将不可用</h3>
<h2>、网络权限</h2>
<h3>7.1 权限说明:用于网络通信</h3>
<h3>7.2 使用场景:</h3>
<h3>5.3 权限控制:您可随时在系统设置中关闭此权限,关闭后局域网传输功能将不可用</h3>
<h2>、网络权限</h2>
<h3>6.1 权限说明:用于网络通信</h3>
<h3>6.2 使用场景:</h3>
<ul>
<li>内容加载与同步</li>
<li>AI对话功能规划中</li>
<li>数据云同步</li>
<li>文件传输</li>
</ul>
<h3>7.3 说明:网络权限为应用基础权限,关闭后大部分功能将不可用</h3>
<h2>、权限管理原则</h2>
<h3>8.1 <span class="highlight">最小必要原则</span>:我们仅申请实现功能所必需的最少权限</h3>
<h3>8.2 <span class="highlight">知情同意原则</span>:申请权限时将明确告知使用目的</h3>
<h3>8.3 <span class="highlight">自主控制原则</span>:您可随时开启或关闭任何权限</h3>
<h3>8.4 <span class="highlight">安全保护原则</span>:通过权限获取的信息仅用于声明的目的</h3>
<h2>、权限变更</h2>
<h3>6.3 说明:网络权限为应用基础权限,关闭后大部分功能将不可用</h3>
<h2>、权限管理原则</h2>
<h3>7.1 <span class="highlight">最小必要原则</span>:我们仅申请实现功能所必需的最少权限</h3>
<h3>7.2 <span class="highlight">知情同意原则</span>:申请权限时将明确告知使用目的</h3>
<h3>7.3 <span class="highlight">自主控制原则</span>:您可随时开启或关闭任何权限</h3>
<h3>7.4 <span class="highlight">安全保护原则</span>:通过权限获取的信息仅用于声明的目的</h3>
<h2>、权限变更</h2>
<p>如我们申请新的权限,将在应用更新说明中明确告知。您可选择不更新应用以拒绝新权限。</p>
<h2>、联系方式</h2>
<h2>、联系方式</h2>
<p>如您对权限使用有任何疑问,请联系:</p>
<ul>
<li>邮箱2821981550@qq.com</li>
<li>应用内:我的 → 关于 → 软件权限</li>
<li>开发者:**弥勒市朋普镇微风暴网络科技工作室**</li>
</ul>
<h2>、法律适用与争议解决</h2>
<h3>11.1 本说明适用<span class="highlight">中华人民共和国法律</span></h3>
<h3>11.2 因本说明产生的争议,双方应友好协商解决;协商不成的,任何一方均可向我们所在地有管辖权的<span class="highlight">人民法院</span>提起诉讼。</h3>
<h2>十、法律适用与争议解决</h2>
<h3>10.1 本说明适用<span class="highlight">中华人民共和国法律</span></h3>
<h3>10.2 因本说明产生的争议,双方应友好协商解决;协商不成的,任何一方均可向我们所在地有管辖权的<span class="highlight">人民法院</span>提起诉讼。</h3>
<p>本协议中任何条款被认定为无效或不可执行的,不影响其他条款的效力。</p>
</div>
<div id="content-en" class="lang-content" style="display:none;">
<p><strong>Xianyan APP</strong> Permission Usage Description</p>
<p>Version: V6.5</p>
<p>Updated: May 20, 2026</p>
<p>Effective: May 21, 2026</p>
<p>Version: V6.6</p>
<p>Updated: June 5, 2026</p>
<p>Effective: June 6, 2026</p>
<h2>I. Permission List</h2>
<h3>1.1 Required Permissions</h3>
<table><thead><tr>

View File

@@ -261,9 +261,9 @@
<div class="content-card">
<div id="content-zh" class="lang-content">
<p><strong>闲言APP</strong> 隐私政策</p>
<p>版本号V6.6</p>
<p>更新日期2026年5月30</p>
<p>生效日期2026年5月31</p>
<p>版本号V6.7</p>
<p>更新日期2026年6月5</p>
<p>生效日期2026年6月6</p>
<p><strong>弥勒市朋普镇微风暴网络科技工作室</strong>(以下简称"我们")深知个人信息对您的重要性,我们将按照法律法规的规定,保护您的个人信息及隐私安全。我们制定本隐私政策以帮助您了解我们如何收集、使用、存储和保护您的个人信息。本政策适用于<strong>闲言APP</strong>提供的所有服务。</p>
<h2>零、定义</h2>
<p>本隐私政策中使用的术语定义如下:</p>
@@ -358,7 +358,7 @@
</tr>
<tr>
<td>文件传输助手</td>
<td>蓝牙/位置/附近设备权限(可选)</td>
<td>位置/附近设备权限(可选)</td>
<td></td>
<td>无法使用文件传输功能</td>
</tr>
@@ -690,9 +690,9 @@
</div>
<div id="content-en" class="lang-content" style="display:none;">
<p><strong>Xianyan APP</strong> Privacy Policy</p>
<p>Version: V6.6</p>
<p>Updated: May 30, 2026</p>
<p>Effective: May 31, 2026</p>
<p>Version: V6.7</p>
<p>Updated: June 5, 2026</p>
<p>Effective: June 6, 2026</p>
<p>Zero. Definitions</p>
<ul>
<li>**Personal Information**: Information that can identify a natural person, including but not limited to name, date of birth, ID number, biometric information, address, phone number, email, etc.</li>
@@ -715,7 +715,7 @@
</ul>
<h3>1.2 Information Collected Automatically During Service Use</h3>
<ul>
<li>Device information: device model, operating system version, device identifier (IDFA/Android ID), screen resolution</li>
<li>Device information: device model, operating system version, device identifier (device_id), screen resolution</li>
<li>Log information: access time, pages visited, click events, crash logs</li>
<li>Network information: network type (WiFi/4G/5G), carrier information</li>
<li>Device fingerprint information: device unique identifier (device_id), User-Agent, used for device identification and security verification</li>

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
闲言APP OAuth社交登录 API 测试脚本
创建时间: 2026-06-05
更新时间: 2026-06-05
作用: 测试OAuth社交登录接口的可用性和参数校验
上次更新: 初始版本
"""
import requests
import json
import sys
BASE = 'https://tools.wktyl.com'
TIMEOUT = 15
def test_install():
"""测试安装数据表"""
print("1. 安装数据表...")
r = requests.get(f'{BASE}/api/oauth/install', timeout=TIMEOUT)
data = r.json()
print(f" 结果: {json.dumps(data, ensure_ascii=False, indent=2)}")
assert data['code'] == 1, f"安装失败: {data['msg']}"
print(" ✅ 安装成功")
return True
def test_config():
"""测试获取OAuth配置"""
print("2. 获取OAuth配置...")
for platform in ['apple', 'google', 'github']:
r = requests.get(f'{BASE}/api/oauth/config', params={'platform': platform}, timeout=TIMEOUT)
data = r.json()
configured = data.get('data', {}).get('configured', False) if data.get('code') == 1 else False
print(f" {platform}: code={data['code']}, configured={configured}")
print(" ✅ 配置接口正常")
return True
def test_config_invalid_platform():
"""测试不支持的平台"""
print("3. 测试不支持的平台...")
r = requests.get(f'{BASE}/api/oauth/config', params={'platform': 'facebook'}, timeout=TIMEOUT)
data = r.json()
assert data['code'] == 0, "应该返回错误"
print(f" 结果: {data['msg']}")
print(" ✅ 不支持的平台正确拒绝")
return True
def test_login_invalid_platform():
"""测试无效平台登录"""
print("4. 测试无效平台登录...")
r = requests.post(f'{BASE}/api/oauth/login', data={
'platform': 'invalid_platform',
'code': 'test',
}, timeout=TIMEOUT)
data = r.json()
assert data['code'] == 0, "应该返回错误"
print(f" 结果: {data['msg']}")
print(" ✅ 无效平台正确拒绝")
return True
def test_login_github_empty_code():
"""测试GitHub空授权码"""
print("5. 测试GitHub空授权码...")
r = requests.post(f'{BASE}/api/oauth/login', data={
'platform': 'github',
'code': '',
}, timeout=TIMEOUT)
data = r.json()
assert data['code'] == 0, "应该返回错误"
print(f" 结果: {data['msg']}")
print(" ✅ 空授权码正确拒绝")
return True
def test_login_apple_empty_token():
"""测试Apple空id_token"""
print("6. 测试Apple空id_token...")
r = requests.post(f'{BASE}/api/oauth/login', data={
'platform': 'apple',
'id_token': '',
}, timeout=TIMEOUT)
data = r.json()
assert data['code'] == 0, "应该返回错误"
print(f" 结果: {data['msg']}")
print(" ✅ 空id_token正确拒绝")
return True
def test_login_google_empty_code():
"""测试Google空授权码"""
print("7. 测试Google空授权码...")
r = requests.post(f'{BASE}/api/oauth/login', data={
'platform': 'google',
'code': '',
}, timeout=TIMEOUT)
data = r.json()
assert data['code'] == 0, "应该返回错误"
print(f" 结果: {data['msg']}")
print(" ✅ 空授权码正确拒绝")
return True
def test_bound_without_login():
"""测试未登录查询绑定列表"""
print("8. 测试未登录查询绑定...")
r = requests.get(f'{BASE}/api/oauth/bound', timeout=TIMEOUT)
data = r.json()
print(f" 结果: code={data['code']}, msg={data['msg']}")
print(" ✅ 未登录正确拒绝")
return True
def test_bind_without_login():
"""测试未登录绑定"""
print("9. 测试未登录绑定...")
r = requests.post(f'{BASE}/api/oauth/bind', data={
'platform': 'github',
'code': 'test',
}, timeout=TIMEOUT)
data = r.json()
print(f" 结果: code={data['code']}, msg={data['msg']}")
print(" ✅ 未登录绑定正确拒绝")
return True
def test_unbind_without_login():
"""测试未登录解绑"""
print("10. 测试未登录解绑...")
r = requests.post(f'{BASE}/api/oauth/unbind', data={
'platform': 'github',
}, timeout=TIMEOUT)
data = r.json()
print(f" 结果: code={data['code']}, msg={data['msg']}")
print(" ✅ 未登录解绑正确拒绝")
return True
def main():
print("=" * 60)
print("闲言APP OAuth社交登录 API 测试")
print("=" * 60)
tests = [
test_install,
test_config,
test_config_invalid_platform,
test_login_invalid_platform,
test_login_github_empty_code,
test_login_apple_empty_token,
test_login_google_empty_code,
test_bound_without_login,
test_bind_without_login,
test_unbind_without_login,
]
passed = 0
failed = 0
for test in tests:
try:
if test():
passed += 1
except Exception as e:
print(f" ❌ 失败: {e}")
failed += 1
print()
print("=" * 60)
print(f"测试完成: ✅ {passed} 通过, ❌ {failed} 失败")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == '__main__':
sys.exit(main())