增加 cloudtentacles发货平台

This commit is contained in:
yml2213
2026-05-02 21:41:43 +08:00
parent dc32eef08b
commit 5b747c7460
20 changed files with 2698 additions and 9 deletions
@@ -0,0 +1,65 @@
// @ts-check
import { createHash, constants, publicEncrypt } from 'node:crypto'
import { createHttpError } from '../../../utils/http.js'
import { resolveCloudtentaclesConfig } from './shared.js'
export function md5CloudtentaclesPassword(password) {
return createHash('md5').update(String(password || ''), 'utf8').digest('hex')
}
export function encryptCloudtentaclesPayload(payload = {}, options = {}) {
const config = resolveCloudtentaclesConfig(options)
const publicKeyPem = String(config.publicKeyPem || '').trim()
if (!publicKeyPem) {
throw createHttpError('cloudtentacles 公钥未配置', {
statusCode: 500,
errorCode: 'cloudtentacles_public_key_missing',
})
}
const timestamp = Number(options.timestamp || Date.now())
const randomValue = String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16)
const normalizedPayload = removeEmptyFields(payload)
const plaintext = JSON.stringify({
t: timestamp,
r: randomValue,
s: String(options.clientSource || config.clientSource || 'ct-client').trim() || 'ct-client',
...normalizedPayload,
})
try {
const encrypted = publicEncrypt(
{
key: publicKeyPem,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
Buffer.from(plaintext, 'utf8'),
)
return {
covert: encrypted.toString('base64'),
t: timestamp,
r: randomValue,
plaintext,
}
} catch (error) {
throw createHttpError('cloudtentacles 加密失败', {
statusCode: 500,
errorCode: 'cloudtentacles_encrypt_failed',
})
}
}
function removeEmptyFields(payload) {
if (!payload || typeof payload !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== '' && value !== null && typeof value !== 'undefined'),
)
}
@@ -0,0 +1,107 @@
// @ts-check
import { createHttpError } from '../../../utils/http.js'
import { logInfo } from '../../../utils/logger.js'
import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js'
export async function cloudtentaclesRequest(pathname, options = {}) {
const config = resolveCloudtentaclesConfig(options)
const url = buildCloudtentaclesUrl(config.baseUrl, pathname, options.searchParams)
const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const method = String(options.method || 'GET').trim().toUpperCase()
const headers = buildCloudtentaclesHeaders({
token: options.token,
contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body),
deviceId: options.deviceId ?? config.deviceId,
deviceType: options.deviceType ?? config.deviceType,
extra: options.headers,
})
try {
const response = await fetch(url, {
method,
headers,
body: normalizeRequestBody(options.body, headers['content-type']),
signal: controller.signal,
})
const rawText = await response.text()
const payload = tryParseJson(rawText)
if (!response.ok) {
throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'cloudtentacles_http_error',
})
}
if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) {
throw createHttpError(String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败'), {
statusCode: Number(options.businessErrorStatusCode || 400),
errorCode: String(options.businessErrorCode || 'cloudtentacles_business_error'),
})
}
logInfo('[cloudtentacles/http]', '请求完成', {
method,
pathname,
status: response.status,
})
return {
url: url.toString(),
status: response.status,
headers: response.headers,
payload,
rawText,
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createHttpError(`cloudtentacles 请求超时(${timeoutMs}ms`, {
statusCode: 504,
errorCode: 'cloudtentacles_request_timeout',
})
}
throw error
} finally {
clearTimeout(timer)
}
}
function inferContentType(body) {
if (body == null) {
return ''
}
if (typeof body === 'string' || body instanceof URLSearchParams) {
return 'application/json'
}
return 'application/json'
}
function normalizeRequestBody(body, contentType) {
if (body == null) {
return undefined
}
if (typeof body === 'string' || body instanceof URLSearchParams) {
return body
}
if (String(contentType || '').includes('application/json')) {
return JSON.stringify(body)
}
return String(body)
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}
@@ -0,0 +1,195 @@
// @ts-check
import { createHttpError } from '../../../utils/http.js'
import { logInfo } from '../../../utils/logger.js'
import { encryptCloudtentaclesPayload, md5CloudtentaclesPassword } from './crypto-service.js'
import { cloudtentaclesRequest } from './http-client.js'
import { resolveCloudtentaclesConfig } from './shared.js'
export async function sendCloudtentaclesSmsCode(payload = {}) {
const username = String(payload.username || '').trim()
const phone = String(payload.phone || '').trim()
if (!username) {
throw createHttpError('cloudtentacles 缺少账号', {
statusCode: 400,
errorCode: 'cloudtentacles_missing_username',
})
}
if (!phone) {
throw createHttpError('cloudtentacles 缺少手机号', {
statusCode: 400,
errorCode: 'cloudtentacles_missing_phone',
})
}
const config = resolveCloudtentaclesConfig(payload)
const encrypted = encryptCloudtentaclesPayload({
account: username,
phone,
}, config)
const result = await cloudtentaclesRequest(config.sendSmsPath, {
...config,
method: 'POST',
body: {
covert: encrypted.covert,
t: encrypted.t,
r: encrypted.r,
},
businessErrorStatusCode: 400,
businessErrorCode: 'cloudtentacles_send_sms_failed',
})
logInfo('[cloudtentacles/session]', '短信验证码发送成功', {
username,
phone: maskPhone(phone),
})
return {
baseUrl: config.baseUrl,
username,
phone,
sentAt: new Date().toISOString(),
responseMessage: String(result.payload?.message || result.payload?.msg || 'success'),
}
}
export async function loginCloudtentaclesSession(payload = {}) {
const username = String(payload.username || '').trim()
const password = String(payload.password || '').trim()
const phone = String(payload.phone || '').trim()
const code = String(payload.code || '').trim()
if (!username) {
throw createHttpError('cloudtentacles 登录缺少账号', {
statusCode: 400,
errorCode: 'cloudtentacles_login_missing_username',
})
}
if (!password) {
throw createHttpError('cloudtentacles 登录缺少密码', {
statusCode: 400,
errorCode: 'cloudtentacles_login_missing_password',
})
}
if (!phone) {
throw createHttpError('cloudtentacles 登录缺少手机号', {
statusCode: 400,
errorCode: 'cloudtentacles_login_missing_phone',
})
}
if (!code) {
throw createHttpError('cloudtentacles 登录缺少短信验证码', {
statusCode: 400,
errorCode: 'cloudtentacles_login_missing_code',
})
}
const config = resolveCloudtentaclesConfig(payload)
const encrypted = encryptCloudtentaclesPayload({
account: username,
password: md5CloudtentaclesPassword(password),
phone,
code,
}, config)
const loginResult = await cloudtentaclesRequest(config.loginPath, {
...config,
method: 'POST',
body: {
covert: encrypted.covert,
t: encrypted.t,
r: encrypted.r,
},
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_login_failed',
})
const token = String(loginResult.payload?.data || '').trim()
if (!token) {
throw createHttpError('cloudtentacles 登录成功但未返回 token', {
statusCode: 502,
errorCode: 'cloudtentacles_missing_token',
})
}
const session = await validateCloudtentaclesSession({
...config,
token,
})
return {
...session,
username,
phone,
responseMessage: String(loginResult.payload?.message || loginResult.payload?.msg || 'success'),
}
}
export async function validateCloudtentaclesSession(payload = {}) {
const token = String(payload.token || '').trim()
if (!token) {
throw createHttpError('cloudtentacles 会话校验缺少 token', {
statusCode: 400,
errorCode: 'cloudtentacles_validate_missing_token',
})
}
const config = resolveCloudtentaclesConfig(payload)
const [userInfoResult, assetResult, permissionResult] = await Promise.all([
cloudtentaclesRequest(config.userInfoPath, {
...config,
method: 'POST',
token,
body: {},
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_user_info_failed',
}),
cloudtentaclesRequest(config.assetPath, {
...config,
method: 'GET',
token,
contentType: 'application/json',
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_asset_failed',
}),
cloudtentaclesRequest(config.permissionPath, {
...config,
method: 'GET',
token,
contentType: 'application/json',
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_permission_failed',
}),
])
return {
baseUrl: config.baseUrl,
token,
loggedInAt: new Date().toISOString(),
userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {},
asset: Number(assetResult.payload?.data || 0),
permissions: Array.isArray(permissionResult.payload?.data)
? permissionResult.payload.data.map((item) => String(item || '').trim()).filter(Boolean)
: [],
}
}
function maskPhone(phone) {
const normalized = String(phone || '').trim()
if (normalized.length < 7) {
return normalized
}
return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -0,0 +1,118 @@
// @ts-check
import { runtimeConfig } from '../../../config/runtime.js'
/** @typedef {import('../../../types/runtime-config.js').RuntimeConfig} RuntimeConfig */
export function resolveCloudtentaclesConfig(overrides = {}) {
/** @type {RuntimeConfig['platforms']['cloudtentacles']} */
const baseConfig = runtimeConfig.platforms?.cloudtentacles || {
baseUrl: '',
timeoutMs: 5000,
sendSmsPath: '/public/verif_code',
loginPath: '/public/login',
userInfoPath: '/user/info',
assetPath: '/user/get_asset',
permissionPath: '/user/get_permission',
publicKeyPem: '',
clientSource: 'ct-client',
deviceId: '-',
deviceType: 0,
}
return {
baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl || 'https://123.207.217.176'),
timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000),
sendSmsPath: normalizePath(overrides.sendSmsPath || baseConfig.sendSmsPath, '/public/verif_code'),
loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/public/login'),
userInfoPath: normalizePath(overrides.userInfoPath || baseConfig.userInfoPath, '/user/info'),
assetPath: normalizePath(overrides.assetPath || baseConfig.assetPath, '/user/get_asset'),
permissionPath: normalizePath(overrides.permissionPath || baseConfig.permissionPath, '/user/get_permission'),
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
deviceId: String(overrides.deviceId ?? baseConfig.deviceId ?? '-').trim() || '-',
deviceType: normalizeInteger(overrides.deviceType ?? baseConfig.deviceType, 0),
}
}
export function buildCloudtentaclesUrl(baseUrl, pathname, searchParams = null) {
const url = new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || 'https://123.207.217.176')
if (searchParams && typeof searchParams === 'object') {
for (const [key, value] of Object.entries(searchParams)) {
if (typeof value === 'undefined' || value === null || value === '') {
continue
}
url.searchParams.set(key, String(value))
}
}
return url
}
export function buildCloudtentaclesHeaders({
token = '',
contentType = 'application/json',
deviceId = '-',
deviceType = 0,
extra = {},
} = {}) {
const headers = {
accept: 'application/json, text/plain, */*',
...(contentType ? { 'content-type': contentType } : {}),
deviceid: String(deviceId || '-').trim() || '-',
devicetype: String(Number(deviceType || 0)),
...normalizeHeaderMap(extra),
}
const normalizedToken = String(token || '').trim()
if (normalizedToken) {
headers.authorization = normalizedToken
}
return headers
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '')
}
function normalizePath(value, fallback) {
const normalized = String(value || '').trim()
if (!normalized) {
return fallback
}
return normalized.startsWith('/') ? normalized : `/${normalized}`
}
function normalizePositiveInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function normalizeInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function normalizeHeaderMap(extra) {
if (!extra || typeof extra !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(extra)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.filter(([key, value]) => key && value),
)
}
function normalizePem(value) {
return String(value || '')
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n')
}
@@ -0,0 +1,71 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-sources.json')
export function getCloudtentaclesSourcesFilePath() {
return CLOUDTENTACLES_SOURCES_FILE_PATH
}
export function getCloudtentaclesSourceConfig() {
return loadCloudtentaclesSourceConfigFromFile()
}
export function saveCloudtentaclesSourceConfig(rawValue) {
const normalized = normalizeCloudtentaclesSourceConfig(rawValue)
fs.mkdirSync(path.dirname(CLOUDTENTACLES_SOURCES_FILE_PATH), { recursive: true })
fs.writeFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadCloudtentaclesSourceConfigFromFile() {
if (!fs.existsSync(CLOUDTENTACLES_SOURCES_FILE_PATH)) {
return createDefaultCloudtentaclesSourceConfig()
}
try {
const rawText = fs.readFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, 'utf8')
return normalizeCloudtentaclesSourceConfig(JSON.parse(rawText))
} catch {
return createDefaultCloudtentaclesSourceConfig()
}
}
function normalizeCloudtentaclesSourceConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
baseUrl: String(source.baseUrl || 'https://123.207.217.176').trim() || 'https://123.207.217.176',
username: String(source.username || '').trim(),
password: String(source.password || '').trim(),
phone: String(source.phone || '').trim(),
deviceId: String(source.deviceId || '-').trim() || '-',
deviceType: normalizeInteger(source.deviceType, 0),
}
}
function createDefaultCloudtentaclesSourceConfig() {
return {
enabled: true,
baseUrl: 'https://123.207.217.176',
username: '',
password: '',
phone: '',
deviceId: '-',
deviceType: 0,
}
}
function normalizeInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}