feat: 合入航空账号、子账号及会员管理更新

Import the supplied management frontend, including request encryption, withdrawal updates, and vendored dependencies.
Use incoming versions for overlapping source files and retain existing environment configuration absent from the supplied directory.
This commit is contained in:
dzc committed 2026-10-29 16:52:46 +08:00
1 parent 401c7c1dd6
commit f087d6ba57
179 files changed
+21027 -9976

No files matched your search

+27 -4
View File
@@ -63,10 +63,33 @@ export const removeLoginForm = () => {
// ========== 租户相关 ==========
export const getTenantId = () => {
return wsCache.get(CACHE_KEY.TenantId)
const tenantEnabled = () => import.meta.env.VITE_APP_TENANT_ENABLE === 'true'
/** 环境变量配置的默认租户编号(单租户场景兜底) */
export const getDefaultTenantId = (): string | undefined => {
const id = import.meta.env.VITE_APP_DEFAULT_TENANT_ID
if (id == null || String(id).trim() === '') return undefined
return String(id).trim()
}
export const setTenantId = (username: string) => {
wsCache.set(CACHE_KEY.TenantId, username)
export const getTenantId = () => {
const cached = wsCache.get(CACHE_KEY.TenantId)
if (cached != null && cached !== '') return cached
return getDefaultTenantId()
}
/** 缓存为空时写入默认租户编号,避免已登录态请求缺少 tenant-id */
export const ensureTenantId = () => {
const current = getTenantId()
if (current == null || current === '') return undefined
if (!wsCache.get(CACHE_KEY.TenantId)) {
wsCache.set(CACHE_KEY.TenantId, current)
}
return current
}
export const setTenantId = (tenantId: string | number) => {
wsCache.set(CACHE_KEY.TenantId, tenantId)
}
export const isTenantEnabled = () => tenantEnabled()
+14 -1
View File
@@ -336,9 +336,17 @@ export const BrokerageRecordBizTypeEnum = {
type: 1,
name: '获得推广佣金'
},
WITHDRAW: {
OPEN_TYPE: {
type: 2,
name: '获得会员佣金'
},
WITHDRAW: {
type: 3,
name: '提现申请'
},
WITHDRAW_REJECT: {
type: 4,
name: '提现申请驳回'
}
}
/**
@@ -385,6 +393,11 @@ export const BrokerageWithdrawTypeEnum = {
ALIPAY: {
type: 4,
name: '支付宝'
},
/** 业务侧实际在用的支付宝提现类型(历史常量名 WECHAT_API) */
ALIPAY_API: {
type: 5,
name: '支付宝'
}
}
+156
View File
@@ -0,0 +1,156 @@
import forge from 'node-forge'
/** 与后端 RespBodyEncryptSupport.PATH_SUFFIXES 对齐 */
const PATH_SUFFIXES = [
'/member/recent/quick-page',
'/member/recent/getById',
'/member/recent/look-wechat',
'/member/user/page'
]
const RESP_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtSt5lLsmloOnhFHdh8NJ
a1L76U/yQWX+S2VPyyXgp7/9TNcbOlygQ0iD0+SXd828wrVkeHo67ELtcsLF/7Sd
eQ1mykvxEswKhAG7SCjaocq9SwMnSre7iHnrtnNKRRH8Sozds67sRJPePz8o8W/Z
r6XKeEuStvZ3E6n2HD7Ug455qIyFMDJnY7ntb3AEWnGdhXsF9Nqi22yuqJrLQOMp
BWu/pTFhpCPr2ECdMdulG491J+snR/UYJZu7Ro65O4Fb3d4EVC9EHKH2cQDLAkVW
ceFliYvihrutYvBkg9aygt5FaQZiUrfie0iVy5FvZLkO3nv+fm9lPTKNyadPb26k
DwIDAQAB
-----END PUBLIC KEY-----`
export const RESP_HEADER_ENC = 'X-Resp-Enc'
export const RESP_HEADER_EK = 'X-Resp-Ek'
export const RESP_AES_KEY = 'respAesKey'
let _publicKey: forge.pki.rsa.PublicKey | null = null
function getPublicKey(): forge.pki.rsa.PublicKey {
if (!_publicKey) {
_publicKey = forge.pki.publicKeyFromPem(RESP_PUBLIC_KEY)
}
return _publicKey
}
function bytesToBinary(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return binary
}
function binaryToBytes(binary: string): Uint8Array {
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i)
}
return out
}
export function needsRespDecrypt(url?: string): boolean {
if (!url) return false
let path = url.split('?')[0]
path = path.replace(/^https?:\/\/[^/]+/i, '')
if (path.startsWith('/admin-api')) {
path = path.slice('/admin-api'.length)
}
if (!path.startsWith('/')) {
path = `/${path}`
}
return PATH_SUFFIXES.some((suffix) => path.endsWith(suffix))
}
function randomBytes(length: number): Uint8Array {
const buf = new Uint8Array(length)
crypto.getRandomValues(buf)
return buf
}
/** RSA/PKCS1 公钥加密 AES Key(与 Java 服务端 decryptAesKey 对齐) */
function rsaEncryptAesKey(aesKey: Uint8Array): string {
const encrypted = getPublicKey().encrypt(bytesToBinary(aesKey), 'RSAES-PKCS1-V1_5')
return forge.util.encode64(encrypted)
}
/** 解析 PKCS#1 v1.5 type-1 填充(Java 私钥 ENCRYPT_MODE 下发 ek) */
function pkcs1V15Type1Message(buf: Uint8Array): Uint8Array {
if (buf.length >= 2 && buf[0] === 0 && buf[1] === 1) {
let i = 2
while (i < buf.length && buf[i] === 0xff) i++
if (i < buf.length && buf[i] === 0) {
return buf.slice(i + 1)
}
}
if (buf.length === 32) return buf
if (buf.length > 32) return buf.slice(buf.length - 32)
throw new Error('RSA 解密 ek 后长度非法')
}
/** 服务端私钥加密下发的 ek,公钥解密(Java Cipher.ENCRYPT_MODE + privateKey) */
function rsaPublicDecryptEk(ekBase64: string): Uint8Array {
const encrypted = forge.util.decode64(ekBase64)
const raw = forge.pki.rsa.decrypt(encrypted, getPublicKey(), true, false)
return pkcs1V15Type1Message(binaryToBytes(raw))
}
/** 请求前:生成 AES Key 并 RSA 加密写入 Header */
export function prepareRespEncryptSession(): { aesKey: Uint8Array; ekBase64: string } {
const aesKey = randomBytes(32)
return { aesKey, ekBase64: rsaEncryptAesKey(aesKey) }
}
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/** 纯 JS AES-GCM,HTTP 非安全上下文下 crypto.subtle 不可用 */
function aesGcmDecrypt(key: Uint8Array, iv: Uint8Array, ctWithTag: Uint8Array): Uint8Array {
const tagLen = 16
const ct = ctWithTag.slice(0, ctWithTag.length - tagLen)
const tag = ctWithTag.slice(ctWithTag.length - tagLen)
const forgeKey = forge.util.createBuffer(bytesToBinary(key))
const decipher = forge.cipher.createDecipher('AES-GCM', forgeKey)
decipher.start({
iv: forge.util.createBuffer(bytesToBinary(iv)),
tag: forge.util.createBuffer(bytesToBinary(tag))
})
decipher.update(forge.util.createBuffer(bytesToBinary(ct)))
decipher.finish()
if (decipher.output.length() === 0) {
throw new Error('AES-GCM 解密失败')
}
return binaryToBytes(decipher.output.getBytes())
}
export function isEncryptedEnvelope(data: unknown): data is Record<string, unknown> {
if (!data || typeof data !== 'object') return false
const enc = (data as Record<string, unknown>).enc
return Number(enc) === 1
}
/** 解密 CommonResult.data 信封(依赖响应中的 ek,或传入请求阶段缓存的 aesKey) */
export async function unwrapRespData(data: unknown, aesKey?: Uint8Array): Promise<unknown> {
if (!isEncryptedEnvelope(data)) return data
const envelope = data as Record<string, unknown>
let key = aesKey
if (!key) {
const ek = envelope.ek
if (typeof ek !== 'string' || !ek) {
throw new Error('加密响应缺少 ek,请升级客户端')
}
key = rsaPublicDecryptEk(ek)
}
const ivB64 = envelope.iv
const ctB64 = envelope.ct
if (typeof ivB64 !== 'string' || typeof ctB64 !== 'string') {
throw new Error('加密响应缺少 iv/ct')
}
const plain = aesGcmDecrypt(key, base64ToBytes(ivB64), base64ToBytes(ctB64))
const text = new TextDecoder().decode(plain)
return JSON.parse(text)
}