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:
1 parent
401c7c1dd6
commit
f087d6ba57
179 files changed
+21027
-9976
No files matched your search
+27
-4
@@ -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
@@ -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: '支付宝'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user