feat(login): 新增网易云扫码/手机号登录与酷狗 token 绑定
This commit is contained in:
1 parent
fd5e2122d3
commit
45f3a4b989
15 files changed
+716
-3
No files matched your search
+3
-1
@@ -4,6 +4,7 @@
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { getKgAuth } from '../auth/store.js'
|
||||
|
||||
let mid = ''
|
||||
|
||||
@@ -16,7 +17,8 @@ const getMid = () => {
|
||||
}
|
||||
|
||||
const getPlayUrl = (hash, albumId) => getMid().then(m => {
|
||||
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}`
|
||||
const auth = getKgAuth()
|
||||
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}${auth ? `&token=${encodeURIComponent(auth.token)}&userid=${auth.userid}` : ''}`
|
||||
return httpFetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { eapi } from '../../musicSdk/wy/utils/crypto.ts'
|
||||
import { getWyCookie } from '../auth/store.js'
|
||||
|
||||
const levelMap = {
|
||||
'128k': 'standard',
|
||||
@@ -21,11 +22,13 @@ export const getMusicUrl = (songInfo, type) => {
|
||||
level: levelMap[type] || 'standard',
|
||||
encodeType: 'flac',
|
||||
}
|
||||
const cookie = getWyCookie()
|
||||
const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: eapi('/api/song/enhance/player/url/v1', data),
|
||||
timeout: 15000,
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
|
||||
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
|
||||
// token 可从酷狗 App 抓包获取(login.token)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { getCredentials, setCredentials } from './store.js'
|
||||
|
||||
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
||||
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
||||
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
|
||||
const APPID = 1005
|
||||
const CLIENTVER = 11430
|
||||
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString()
|
||||
const randomString = (n) => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
}
|
||||
|
||||
/** 设备标识持久化在凭据里,保证同一登录态的请求指纹稳定 */
|
||||
const device = () => {
|
||||
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
|
||||
if (!dev.mid) dev.mid = calculateMid(dev.guid)
|
||||
setCredentials('_kg_device', dev)
|
||||
return dev
|
||||
}
|
||||
|
||||
const androidSign = (params, data = '') => {
|
||||
const s = Object.keys(params).sort()
|
||||
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
|
||||
.join('')
|
||||
return md5(SALT_SIGN + s + data + SALT_SIGN)
|
||||
}
|
||||
|
||||
/** 网关移动端签名请求(gateway.kugou.com + x-router) */
|
||||
const gatewayRequest = async(url, { params: extraParams, data, router }) => {
|
||||
const dev = device()
|
||||
const mid = dev.mid || (dev.mid = calculateMid(dev.guid))
|
||||
setCredentials('_kg_device', dev)
|
||||
|
||||
const params = {
|
||||
dfid: dev.dfid, mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||
clienttime: Math.floor(Date.now() / 1000),
|
||||
...extraParams,
|
||||
}
|
||||
const signature = androidSign(params, data ? JSON.stringify(data) : '')
|
||||
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
|
||||
const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
dfid: dev.dfid, mid, clienttime: String(params.clienttime),
|
||||
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
|
||||
'x-router': router,
|
||||
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
|
||||
},
|
||||
body: data ?? undefined,
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
return r.body ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
|
||||
* p = RSA 原始加密(零填充){ token, clienttime }
|
||||
*/
|
||||
export const verifyToken = async({ token, userid }) => {
|
||||
const unixSec = Math.floor(Date.now() / 1000)
|
||||
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
|
||||
// 原始 RSA(无填充):左侧零填充到密钥长度(128 字节)
|
||||
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload])
|
||||
const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
|
||||
|
||||
const body = await gatewayRequest('/v3/get_my_info', {
|
||||
router: 'usercenter.kugou.com',
|
||||
params: { plat: 1 },
|
||||
data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 },
|
||||
})
|
||||
if (body.status !== 1 || !body.data) {
|
||||
const err = new Error(body.error ?? 'token 无效或已过期')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
return {
|
||||
userid: body.data.userid ?? Number(userid),
|
||||
nickname: body.data.nickname ?? body.data.username ?? null,
|
||||
vip: body.data.vip ? Boolean(body.data.vip) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定并验证 kg token */
|
||||
export const loginByToken = async({ token, userid }) => {
|
||||
const profile = await verifyToken({ token, userid })
|
||||
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前 kg 登录资料(未登录返回 null) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('kg')
|
||||
return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
// src/auth/store.js 的类型声明(实现为同目录 store.js)
|
||||
export interface WyCredentials {
|
||||
cookie: string
|
||||
musicU: string
|
||||
userid?: number
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
vipType?: number | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface KgCredentials {
|
||||
token: string
|
||||
userid: number | string
|
||||
nickname?: string | null
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface KgDevice {
|
||||
guid: string
|
||||
mid: string
|
||||
dfid: string
|
||||
}
|
||||
|
||||
export declare function getCredentials(platform: 'wy' | 'kg' | '_kg_device'): WyCredentials | KgCredentials | KgDevice | null
|
||||
export declare function setCredentials(platform: 'wy' | 'kg' | '_kg_device', credentials: object): void
|
||||
export declare function clearCredentials(platform: 'wy' | 'kg' | '_kg_device'): void
|
||||
export declare function getWyCookie(): string
|
||||
export declare function getKgAuth(): { token: string; userid: number | string } | null
|
||||
@@ -0,0 +1,50 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: { cookie, musicU, userid?, nickname?, updatedAt }, kg: { token, userid, nickname?, updatedAt } }
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
|
||||
let cache = null
|
||||
|
||||
const load = () => {
|
||||
if (!cache) {
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = {}
|
||||
}
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const save = (data) => {
|
||||
cache = data
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export const getCredentials = (platform) => load()[platform] ?? null
|
||||
|
||||
export const setCredentials = (platform, credentials) => {
|
||||
const data = load()
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
|
||||
save(data)
|
||||
}
|
||||
|
||||
export const clearCredentials = (platform) => {
|
||||
const data = load()
|
||||
delete data[platform]
|
||||
save(data)
|
||||
}
|
||||
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = () => getCredentials('wy')?.cookie || ''
|
||||
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = () => {
|
||||
const cred = getCredentials('kg')
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
|
||||
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.js'
|
||||
import { weapi } from '../../musicSdk/wy/utils/crypto.ts'
|
||||
import { getWyCookie, setCredentials } from './store.js'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
|
||||
const requestWeapi = async(path, data, cookie = getWyCookie()) => {
|
||||
const r = await httpFetch(`https://music.163.com/weapi${path}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
origin: 'https://music.163.com',
|
||||
Referer: 'https://music.163.com/',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi(data),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (r.statusCode !== 200) throw new Error('网易请求失败')
|
||||
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
|
||||
}
|
||||
|
||||
/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */
|
||||
const requestApi = async(path, data) => {
|
||||
const r = await httpFetch(`https://music.163.com${path}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
origin: 'https://music.163.com',
|
||||
Referer: 'https://music.163.com/',
|
||||
},
|
||||
form: data,
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (r.statusCode !== 200) throw new Error('网易请求失败')
|
||||
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
|
||||
}
|
||||
|
||||
/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */
|
||||
const extractLoginCookie = (setCookie) => {
|
||||
const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ')
|
||||
const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1]
|
||||
return { cookie: joined, musicU }
|
||||
}
|
||||
|
||||
/** 登录成功后拉取账号资料 */
|
||||
const fetchProfile = async() => {
|
||||
const { body } = await requestWeapi('/api/w/nuser/account/get', {})
|
||||
return {
|
||||
userid: body?.account?.id ?? null,
|
||||
nickname: body?.profile?.nickname ?? null,
|
||||
avatar: body?.profile?.avatarUrl ?? null,
|
||||
vipType: body?.profile?.vipType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用登录 cookie(成功后调用) */
|
||||
const applyLogin = async(setCookie) => {
|
||||
const { cookie, musicU } = extractLoginCookie(setCookie)
|
||||
if (!musicU) throw new Error('登录响应中缺少 MUSIC_U')
|
||||
const profile = await fetchProfile()
|
||||
setCredentials('wy', { cookie, musicU, ...profile })
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 创建扫码登录 key 与二维码内容 */
|
||||
export const qrKey = async() => {
|
||||
const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 })
|
||||
if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败')
|
||||
const key = body.unikey
|
||||
const qrurl = `https://music.163.com/login?codekey=${key}`
|
||||
const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
|
||||
return { key, qrurl, qrimg }
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询扫码状态
|
||||
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
|
||||
*/
|
||||
export const qrCheck = async(key) => {
|
||||
const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 })
|
||||
if (body.code === 803) {
|
||||
const profile = await applyLogin(setCookie)
|
||||
return { code: 803, message: '登录成功', profile }
|
||||
}
|
||||
const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' }
|
||||
return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha)
|
||||
*/
|
||||
export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => {
|
||||
const data = {
|
||||
type: '1',
|
||||
https: 'true',
|
||||
phone,
|
||||
countrycode,
|
||||
remember: 'true',
|
||||
[captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'),
|
||||
}
|
||||
const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data)
|
||||
if (body.code !== 200) {
|
||||
const err = new Error(body.message ?? body.msg ?? '登录失败')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
const profile = await applyLogin(setCookie)
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前账号资料(未登录返回 null) */
|
||||
export const status = async() => {
|
||||
if (!getWyCookie()) return null
|
||||
return fetchProfile()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json
|
||||
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
|
||||
|
||||
import * as wyAuth from './auth/wy.js'
|
||||
import * as kgAuth from './auth/kg.js'
|
||||
import { clearCredentials, getCredentials } from './auth/store.js'
|
||||
|
||||
const fail = (reply, err) => {
|
||||
const status = err.status ?? 502
|
||||
return reply.code(status).send({ code: status, msg: err.message })
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册登录相关路由
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
*/
|
||||
export const loginRoutes = async(fastify) => {
|
||||
// ---------- 网易云 ----------
|
||||
|
||||
fastify.post('/login/wy/qr', {
|
||||
schema: {
|
||||
summary: '获取网易云扫码登录二维码',
|
||||
description: '用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async() => wyAuth.qrKey())
|
||||
|
||||
fastify.post('/login/wy/qr/check', {
|
||||
schema: {
|
||||
summary: '轮询网易云扫码状态',
|
||||
description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。',
|
||||
tags: ['登录'],
|
||||
body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await wyAuth.qrCheck(request.body.key)
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post('/login/wy/cellphone', {
|
||||
schema: {
|
||||
summary: '网易云手机号登录',
|
||||
description: 'password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。',
|
||||
tags: ['登录'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
phone: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
captcha: { type: 'string' },
|
||||
countrycode: { type: 'string', default: '86' },
|
||||
},
|
||||
required: ['phone'],
|
||||
},
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await wyAuth.cellphoneLogin(request.body)
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/wy', {
|
||||
schema: { summary: '退出网易云登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('wy')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 酷狗 ----------
|
||||
|
||||
fastify.post('/login/kg', {
|
||||
schema: {
|
||||
summary: '绑定酷狗 token',
|
||||
description: 'token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。手机号/扫码登录暂未实现。',
|
||||
tags: ['登录'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
userid: { type: ['integer', 'string'] },
|
||||
},
|
||||
required: ['token', 'userid'],
|
||||
},
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await kgAuth.loginByToken(request.body)
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/kg', {
|
||||
schema: { summary: '退出酷狗登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('kg')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 通用 ----------
|
||||
|
||||
fastify.get('/login/status', {
|
||||
schema: {
|
||||
summary: '查看登录状态',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg 为已存凭据。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async() => {
|
||||
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
|
||||
return {
|
||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||
kg: kg ? { logged: true, ...kg } : { logged: false },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { dirname, join } from 'node:path'
|
||||
import musicSdk from '../musicSdk/index.js'
|
||||
import { ApiError } from './http/api-error.js'
|
||||
import { musicRoutes } from './routes.js'
|
||||
import { loginRoutes } from './routes-login.js'
|
||||
import { COMPONENT_SCHEMAS } from './openapi/components.js'
|
||||
import { redocHtml } from './openapi/ui.js'
|
||||
|
||||
@@ -144,6 +145,7 @@ fastify.register(swagger, {
|
||||
{ name: '榜单', description: '排行榜' },
|
||||
{ name: '歌单', description: '歌单与热搜' },
|
||||
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
||||
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg token)与状态' },
|
||||
{ name: '系统', description: '健康检查、平台信息、文档' },
|
||||
],
|
||||
},
|
||||
@@ -180,6 +182,7 @@ fastify.get('/', {
|
||||
// ---- 业务路由 ----
|
||||
|
||||
fastify.register(musicRoutes)
|
||||
fastify.register(loginRoutes)
|
||||
|
||||
export const startServer = async(port = PORT) => {
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
|
||||
@@ -42,6 +42,25 @@ const postRaw = (path) => new Promise((resolve, reject) => {
|
||||
req.end()
|
||||
})
|
||||
|
||||
// JSON POST(登录等写入接口)
|
||||
const postJson = (path, body) => new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body)
|
||||
const req = http.request(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
||||
}, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
body: (() => { try { return JSON.parse(data) } catch { return null } })(),
|
||||
}))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
|
||||
const test = async(name, fn) => {
|
||||
try {
|
||||
const result = await fn()
|
||||
@@ -360,6 +379,28 @@ await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() =>
|
||||
return spec.openapi
|
||||
})
|
||||
|
||||
// ---- 登录 ----
|
||||
|
||||
await test('登录状态 /login/status', async() => {
|
||||
const r = await get('/login/status')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整')
|
||||
return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}`
|
||||
})
|
||||
|
||||
await test('扫码二维码创建 POST /login/wy/qr', async() => {
|
||||
const r = await postJson('/login/wy/qr', {})
|
||||
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
||||
if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整')
|
||||
return r.body.data.key.slice(0, 18) + '…'
|
||||
})
|
||||
|
||||
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
||||
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
||||
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||||
return r.body?.msg
|
||||
})
|
||||
|
||||
// 汇总
|
||||
const passed = results.filter(r => r.ok).length
|
||||
const failed = results.filter(r => !r.ok)
|
||||
|
||||
Reference in new issue
Block a user