fix: 全面 QA 审计修复 — 安全加固/代码质量/跨平台一致性/测试覆盖
Phase 0 安全热修复 (CRITICAL): - 外部化微信 appid/secret 到 ERP__WECHAT__APPID/SECRET 环境变量 - 正确连接 HealthCrypto 到 ERP__HEALTH__AES_KEY/HMAC_KEY 环境变量 - 外部化小程序加密密钥到 TARO_APP_ENCRYPTION_KEY 环境变量 - 移除小程序 auth store 中的敏感信息 console.log Phase 1 安全加固: - 微信自动注册 display_name 添加 sanitize 防止 XSS - 测试数据库凭据改为从 TEST_DB_URL 环境变量读取 Phase 2 代码质量: - 提取 useThemeMode hook 消除 22 处重复暗色模式检测 - 提取共享健康常量到 constants/health.ts - 拆分 patient_service.rs 脱敏函数到 masking.rs - 移除未使用的 i18next/react-i18next 依赖 - 移除未使用的 api/errors.ts 和 erp-auth/anyhow 依赖 Phase 3 测试覆盖: - 新增 5 个患者模块集成测试 (CRUD/租户隔离/验证/软删除) Phase 4 跨平台一致性: - 统一小程序 Patient.birthday → birth_date 匹配后端 - 统一小程序 Appointment.time_slot → start_time/end_time 匹配后端 Phase 5 架构: - 微信登录添加多租户 TODO 注释 - 更新 wiki/infrastructure.md 环境变量文档
This commit is contained in:
@@ -27,7 +27,9 @@ interface DoctorItem {
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
time_slot: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
label: string;
|
||||
available_count: number;
|
||||
}
|
||||
|
||||
@@ -85,7 +87,9 @@ export default function AppointmentCreate() {
|
||||
const daySlots = schedules
|
||||
.filter((s: any) => (s.date || s.appointment_date) === date)
|
||||
.map((s: any) => ({
|
||||
time_slot: s.time_slot || `${s.start_time || ''}-${s.end_time || ''}`,
|
||||
start_time: s.start_time || '',
|
||||
end_time: s.end_time || '',
|
||||
label: `${s.start_time || ''}-${s.end_time || ''}`,
|
||||
available_count: s.available_count ?? (s.max_patients ?? 10),
|
||||
}));
|
||||
setTimeSlots(daySlots);
|
||||
@@ -105,11 +109,13 @@ export default function AppointmentCreate() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const selectedSlot = timeSlots.find((s) => s.label === timeSlot);
|
||||
await createAppointment({
|
||||
patient_id: currentPatient.id,
|
||||
doctor_id: selectedDoctor.id,
|
||||
appointment_date: appointmentDate,
|
||||
time_slot: timeSlot,
|
||||
start_time: selectedSlot?.start_time || timeSlot,
|
||||
end_time: selectedSlot?.end_time || timeSlot,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '预约成功', icon: 'success' });
|
||||
@@ -118,7 +124,7 @@ export default function AppointmentCreate() {
|
||||
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
|
||||
if (tmplId) {
|
||||
try {
|
||||
await Taro.requestSubscribeMessage({ tmplIds: [tmplId] });
|
||||
await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] });
|
||||
} catch { /* 用户拒绝 */ }
|
||||
}
|
||||
setTimeout(() => Taro.navigateBack(), 1500);
|
||||
@@ -219,11 +225,11 @@ export default function AppointmentCreate() {
|
||||
<View className='slot-grid'>
|
||||
{timeSlots.map((slot) => (
|
||||
<View
|
||||
className={`slot-card ${getSlotStyle(slot.available_count)} ${timeSlot === slot.time_slot ? 'slot-selected' : ''}`}
|
||||
key={slot.time_slot}
|
||||
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.time_slot) : undefined}
|
||||
className={`slot-card ${getSlotStyle(slot.available_count)} ${timeSlot === slot.label ? 'slot-selected' : ''}`}
|
||||
key={slot.label}
|
||||
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined}
|
||||
>
|
||||
<Text className='slot-time'>{slot.time_slot}</Text>
|
||||
<Text className='slot-time'>{slot.label}</Text>
|
||||
<Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function AppointmentDetail() {
|
||||
<Text className='header-title'>预约详情</Text>
|
||||
<View className='header-placeholder' />
|
||||
</View>
|
||||
<ErrorState message='未找到预约信息' />
|
||||
<ErrorState text='未找到预约信息' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export default function AppointmentDetail() {
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='info-label'>就诊时段</Text>
|
||||
<Text className='info-value'>{appointment.time_slot}</Text>
|
||||
<Text className='info-value'>{appointment.start_time} - {appointment.end_time}</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='info-label'>预约单号</Text>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function AppointmentList() {
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='info-icon'>🕐</Text>
|
||||
<Text className='info-text'>{item.time_slot}</Text>
|
||||
<Text className='info-text'>{item.start_time} - {item.end_time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function FamilyAdd() {
|
||||
const [genderIdx, setGenderIdx] = useState(
|
||||
editData?.gender === 'female' ? 1 : 0
|
||||
);
|
||||
const [birthDate, setBirthDate] = useState(editData?.birthday || '');
|
||||
const [birthDate, setBirthDate] = useState(editData?.birth_date || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,14 +25,14 @@ export default function FamilyList() {
|
||||
|
||||
useDidShow(() => {
|
||||
fetchPatients();
|
||||
}, [fetchPatients]);
|
||||
});
|
||||
|
||||
const handleSelect = (patient: Patient) => {
|
||||
setCurrentPatient({
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
gender: patient.gender,
|
||||
birthday: patient.birthday,
|
||||
birth_date: patient.birth_date,
|
||||
relation: patient.relation || '本人',
|
||||
});
|
||||
Taro.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' });
|
||||
|
||||
@@ -6,7 +6,8 @@ export interface Appointment {
|
||||
doctor_name: string;
|
||||
department: string;
|
||||
appointment_date: string;
|
||||
time_slot: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
version: number;
|
||||
}
|
||||
@@ -22,7 +23,8 @@ export interface DoctorSchedule {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
time_slot: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
available_count: number;
|
||||
}
|
||||
|
||||
@@ -39,7 +41,8 @@ export async function createAppointment(data: {
|
||||
doctor_id: string;
|
||||
schedule_id?: string;
|
||||
appointment_date: string;
|
||||
time_slot: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
reason?: string;
|
||||
}) {
|
||||
return api.post<Appointment>('/health/appointments', data);
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface PatientInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
birth_date?: string;
|
||||
relation: string;
|
||||
}
|
||||
|
||||
@@ -42,3 +42,8 @@ export async function wechatBindPhone(openid: string, encryptedData: string, iv:
|
||||
export async function getPatients() {
|
||||
return api.get<PatientInfo[]>('/health/patients');
|
||||
}
|
||||
|
||||
/** 开发模式:用户名密码直登 */
|
||||
export async function devLogin(username: string, password: string) {
|
||||
return api.post<LoginResp['token']>('/auth/login', { username, password });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface Patient {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
birth_date?: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
relation?: string;
|
||||
@@ -18,7 +18,7 @@ export async function listPatients() {
|
||||
export async function createPatient(data: {
|
||||
name: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
birth_date?: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
}) {
|
||||
@@ -28,7 +28,7 @@ export async function createPatient(data: {
|
||||
export interface PatientUpdateInput {
|
||||
name?: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
birth_date?: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
relation?: string;
|
||||
|
||||
@@ -50,11 +50,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureSet('access_token', access_token);
|
||||
secureSet('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
Taro.setStorageSync('tenant_id', user.tenant_id || '');
|
||||
Taro.setStorageSync('tenant_id', (user as any).tenant_id || '');
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
return true;
|
||||
}
|
||||
// 未绑定手机号,缓存 openid 供后续 bindPhone 使用
|
||||
Taro.setStorageSync('wechat_openid', resp.openid);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const XOR_KEY = 'hms_mp_2026_secure_key';
|
||||
const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || '';
|
||||
|
||||
function xorTransform(value: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
result += String.fromCharCode(value.charCodeAt(i) ^ XOR_KEY.charCodeAt(i % XOR_KEY.length));
|
||||
}
|
||||
return result;
|
||||
function encrypt(plaintext: string): string {
|
||||
if (!ENCRYPTION_KEY) return plaintext;
|
||||
return CryptoJS.AES.encrypt(plaintext, ENCRYPTION_KEY).toString();
|
||||
}
|
||||
|
||||
function toBase64(str: string): string {
|
||||
return btoa(unescape(encodeURIComponent(str)));
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
function decrypt(ciphertext: string): string {
|
||||
if (!ENCRYPTION_KEY) return ciphertext;
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(b64)));
|
||||
const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY);
|
||||
return bytes.toString(CryptoJS.enc.Utf8);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function secureSet(key: string, value: string): void {
|
||||
const obfuscated = toBase64(xorTransform(value));
|
||||
Taro.setStorageSync(key, obfuscated);
|
||||
const encrypted = encrypt(value);
|
||||
Taro.setStorageSync(key, encrypted);
|
||||
}
|
||||
|
||||
export function secureGet(key: string): string {
|
||||
const raw = Taro.getStorageSync(key);
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
try {
|
||||
return xorTransform(fromBase64(raw));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return decrypt(raw);
|
||||
}
|
||||
|
||||
export function secureRemove(key: string): void {
|
||||
|
||||
Reference in New Issue
Block a user