fix(mp): 小程序真机 TextEncoder 不可用 + DevTools getPhoneNumber 绕过
- secure-storage-aes.ts 用纯 JS 实现 UTF-8 编解码替代 TextEncoder/TextDecoder - 登录页绑定手机号步骤:DevTools/模拟器中跳过微信 SDK 直接调后端 mock
This commit is contained in:
@@ -99,6 +99,24 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK
|
||||||
|
const handleDevBindPhone = async () => {
|
||||||
|
try {
|
||||||
|
const success = await bindPhone('dev_mock', 'dev_mock');
|
||||||
|
if (success) {
|
||||||
|
navigateAfterLogin();
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '绑定失败',
|
||||||
|
content: err instanceof Error ? err.message : '绑定失败',
|
||||||
|
confirmText: '重新登录',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => { if (res.confirm) setNeedBind(false); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="login-page">
|
<View className="login-page">
|
||||||
{/* 品牌区 */}
|
{/* 品牌区 */}
|
||||||
@@ -120,15 +138,29 @@ export default function Login() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className="login-bind-section">
|
<View className="login-bind-section">
|
||||||
<Button
|
{/* 真机:微信手机号授权 */}
|
||||||
className="login-btn-bind"
|
{!(IS_DEV || IS_SIMULATOR) && (
|
||||||
openType="getPhoneNumber"
|
<Button
|
||||||
onGetPhoneNumber={handleGetPhone}
|
className="login-btn-bind"
|
||||||
loading={loading}
|
openType="getPhoneNumber"
|
||||||
disabled={loading}
|
onGetPhoneNumber={handleGetPhone}
|
||||||
>
|
loading={loading}
|
||||||
授权手机号完成绑定
|
disabled={loading}
|
||||||
</Button>
|
>
|
||||||
|
授权手机号完成绑定
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* DevTools:跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */}
|
||||||
|
{(IS_DEV || IS_SIMULATOR) && (
|
||||||
|
<Button
|
||||||
|
className="login-btn-bind"
|
||||||
|
onClick={handleDevBindPhone}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
授权手机号完成绑定(开发模式)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ declare const wx: {
|
|||||||
getRandomValuesSync?: (params: { length: number }) => ArrayBuffer;
|
getRandomValuesSync?: (params: { length: number }) => ArrayBuffer;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
|
// 小程序环境无 TextEncoder/TextDecoder,用纯 JS 替代
|
||||||
|
function utf8Encode(str: string): Uint8Array {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
let code = str.charCodeAt(i);
|
||||||
|
if (code < 0x80) {
|
||||||
|
bytes.push(code);
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||||
|
} else if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
// surrogate pair
|
||||||
|
const hi = code;
|
||||||
|
const lo = str.charCodeAt(++i);
|
||||||
|
code = ((hi - 0xd800) << 10) + (lo - 0xdc00) + 0x10000;
|
||||||
|
bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||||
|
} else {
|
||||||
|
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8Decode(bytes: Uint8Array): string {
|
||||||
|
let str = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < bytes.length) {
|
||||||
|
const b = bytes[i++];
|
||||||
|
if (b < 0x80) {
|
||||||
|
str += String.fromCharCode(b);
|
||||||
|
} else if (b < 0xe0) {
|
||||||
|
str += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f));
|
||||||
|
} else if (b < 0xf0) {
|
||||||
|
str += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f));
|
||||||
|
} else {
|
||||||
|
const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f);
|
||||||
|
const hi = ((cp - 0x10000) >> 10) + 0xd800;
|
||||||
|
const lo = ((cp - 0x10000) & 0x3ff) + 0xdc00;
|
||||||
|
str += String.fromCharCode(hi, lo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
function getEncryptionKey(): Uint8Array | null {
|
function getEncryptionKey(): Uint8Array | null {
|
||||||
const hex = process.env.TARO_APP_ENCRYPTION_KEY || '';
|
const hex = process.env.TARO_APP_ENCRYPTION_KEY || '';
|
||||||
if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) {
|
if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||||
@@ -52,7 +95,7 @@ function aesEncrypt(plaintext: string): string | null {
|
|||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
const nonce = generateNonce();
|
const nonce = generateNonce();
|
||||||
const cipher = gcm(key, nonce);
|
const cipher = gcm(key, nonce);
|
||||||
const data = new TextEncoder().encode(plaintext);
|
const data = utf8Encode(plaintext);
|
||||||
const ciphertext = cipher.encrypt(data);
|
const ciphertext = cipher.encrypt(data);
|
||||||
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
||||||
combined.set(nonce, 0);
|
combined.set(nonce, 0);
|
||||||
@@ -73,7 +116,7 @@ function aesDecrypt(encoded: string): string | null {
|
|||||||
const ciphertext = combined.slice(NONCE_LENGTH);
|
const ciphertext = combined.slice(NONCE_LENGTH);
|
||||||
const cipher = gcm(key, nonce);
|
const cipher = gcm(key, nonce);
|
||||||
const plaintext = cipher.decrypt(ciphertext);
|
const plaintext = cipher.decrypt(ciphertext);
|
||||||
return new TextDecoder().decode(plaintext);
|
return utf8Decode(plaintext);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -96,10 +139,16 @@ export function secureSet(key: string, value: string): void {
|
|||||||
const encrypted = aesEncrypt(value);
|
const encrypted = aesEncrypt(value);
|
||||||
if (encrypted) {
|
if (encrypted) {
|
||||||
Taro.setStorageSync(STORAGE_PREFIX + key, encrypted);
|
Taro.setStorageSync(STORAGE_PREFIX + key, encrypted);
|
||||||
} else {
|
return;
|
||||||
// dev mode: store plaintext with prefix for compatibility
|
|
||||||
Taro.setStorageSync(STORAGE_PREFIX + key, value);
|
|
||||||
}
|
}
|
||||||
|
// 密钥不可用时的降级策略
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
// 生产环境不允许明文存储敏感数据
|
||||||
|
console.error(`[secure-storage] 拒绝明文写入 production key: ${key} — ENCRYPTION_KEY 未配置`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// dev mode: store plaintext with prefix for compatibility
|
||||||
|
Taro.setStorageSync(STORAGE_PREFIX + key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function secureGet(key: string): string {
|
export function secureGet(key: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user