perf(database): 优化数据库查询性能和前端请求处理
- 优化ActivityDefinitionManageMapper.xml中的分页查询,减少JOIN操作并使用索引友好的写法 - 修复purchaseinventory组件中API调用的数据传递格式问题 - 将前端请求超时时间从60秒增加到120秒以配合后端超时设置 - 在手术申请页面添加远程搜索防抖功能,避免频繁API调用 - 重构SurgeryAppServiceImpl中的名称字段填充逻辑,使用批量查询减少数据库访问次数 - 优化SurgeryMapper.xml中的分页查询,使用子查询预加载关联数据并减少不必要的JOIN
This commit is contained in:
@@ -533,94 +533,94 @@ public class SurgeryAppServiceImpl implements ISurgeryAppService {
|
||||
|
||||
/**
|
||||
* 填充手术记录中的名称字段
|
||||
* 根据ID反向查询用户表、机构表、手术室表、患者表、就诊表,填充对应的名称字段
|
||||
* 优化:使用批量查询减少数据库访问次数
|
||||
*
|
||||
* @param surgery 手术实体对象
|
||||
*/
|
||||
private void fillSurgeryNameFields(Surgery surgery) {
|
||||
// 收集所有需要查询的ID
|
||||
Set<Long> practitionerIds = new HashSet<>();
|
||||
Set<Long> orgIds = new HashSet<>();
|
||||
Set<Long> otherIds = new HashSet<>();
|
||||
|
||||
// 收集Practitioner IDs
|
||||
if (surgery.getMainSurgeonId() != null) practitionerIds.add(surgery.getMainSurgeonId());
|
||||
if (surgery.getAnesthetistId() != null) practitionerIds.add(surgery.getAnesthetistId());
|
||||
if (surgery.getAssistant1Id() != null) practitionerIds.add(surgery.getAssistant1Id());
|
||||
if (surgery.getAssistant2Id() != null) practitionerIds.add(surgery.getAssistant2Id());
|
||||
if (surgery.getScrubNurseId() != null) practitionerIds.add(surgery.getScrubNurseId());
|
||||
if (surgery.getApplyDoctorId() != null) practitionerIds.add(surgery.getApplyDoctorId());
|
||||
|
||||
// 收集Organization IDs
|
||||
if (surgery.getOrgId() != null) orgIds.add(surgery.getOrgId());
|
||||
if (surgery.getApplyDeptId() != null) orgIds.add(surgery.getApplyDeptId());
|
||||
|
||||
// 批量查询并缓存结果
|
||||
Map<Long, String> practitionerNameMap = new HashMap<>();
|
||||
Map<Long, String> orgNameMap = new HashMap<>();
|
||||
|
||||
// 批量查询Practitioner
|
||||
if (!practitionerIds.isEmpty()) {
|
||||
List<com.openhis.administration.domain.Practitioner> practitioners = practitionerService.listByIds(practitionerIds);
|
||||
for (com.openhis.administration.domain.Practitioner p : practitioners) {
|
||||
practitionerNameMap.put(p.getId(), p.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询Organization
|
||||
if (!orgIds.isEmpty()) {
|
||||
List<Organization> orgs = organizationService.listByIds(orgIds);
|
||||
for (Organization o : orgs) {
|
||||
orgNameMap.put(o.getId(), o.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充患者姓名
|
||||
if (surgery.getPatientId() != null) {
|
||||
if (surgery.getPatientId() != null && surgery.getPatientName() == null) {
|
||||
Patient patient = patientService.getById(surgery.getPatientId());
|
||||
if (patient != null) {
|
||||
surgery.setPatientName(patient.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充主刀医生姓名(使用practitionerId查询Practitioner表)
|
||||
if (surgery.getMainSurgeonId() != null) {
|
||||
com.openhis.administration.domain.Practitioner mainSurgeon = practitionerService.getById(surgery.getMainSurgeonId());
|
||||
if (mainSurgeon != null) {
|
||||
surgery.setMainSurgeonName(mainSurgeon.getName());
|
||||
// 使用缓存填充名称
|
||||
if (surgery.getMainSurgeonId() != null && surgery.getMainSurgeonName() == null) {
|
||||
surgery.setMainSurgeonName(practitionerNameMap.get(surgery.getMainSurgeonId()));
|
||||
}
|
||||
if (surgery.getAnesthetistId() != null && surgery.getAnesthetistName() == null) {
|
||||
surgery.setAnesthetistName(practitionerNameMap.get(surgery.getAnesthetistId()));
|
||||
}
|
||||
|
||||
// 填充麻醉医生姓名(使用practitionerId查询Practitioner表)
|
||||
if (surgery.getAnesthetistId() != null) {
|
||||
com.openhis.administration.domain.Practitioner anesthetist = practitionerService.getById(surgery.getAnesthetistId());
|
||||
if (anesthetist != null) {
|
||||
surgery.setAnesthetistName(anesthetist.getName());
|
||||
if (surgery.getAssistant1Id() != null && surgery.getAssistant1Name() == null) {
|
||||
surgery.setAssistant1Name(practitionerNameMap.get(surgery.getAssistant1Id()));
|
||||
}
|
||||
if (surgery.getAssistant2Id() != null && surgery.getAssistant2Name() == null) {
|
||||
surgery.setAssistant2Name(practitionerNameMap.get(surgery.getAssistant2Id()));
|
||||
}
|
||||
|
||||
// 填充助手1姓名(使用practitionerId查询Practitioner表)
|
||||
if (surgery.getAssistant1Id() != null) {
|
||||
com.openhis.administration.domain.Practitioner assistant1 = practitionerService.getById(surgery.getAssistant1Id());
|
||||
if (assistant1 != null) {
|
||||
surgery.setAssistant1Name(assistant1.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充助手2姓名(使用practitionerId查询Practitioner表)
|
||||
if (surgery.getAssistant2Id() != null) {
|
||||
com.openhis.administration.domain.Practitioner assistant2 = practitionerService.getById(surgery.getAssistant2Id());
|
||||
if (assistant2 != null) {
|
||||
surgery.setAssistant2Name(assistant2.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充巡回护士姓名(使用practitionerId查询Practitioner表)
|
||||
if (surgery.getScrubNurseId() != null) {
|
||||
com.openhis.administration.domain.Practitioner scrubNurse = practitionerService.getById(surgery.getScrubNurseId());
|
||||
if (scrubNurse != null) {
|
||||
surgery.setScrubNurseName(scrubNurse.getName());
|
||||
if (surgery.getScrubNurseId() != null && surgery.getScrubNurseName() == null) {
|
||||
surgery.setScrubNurseName(practitionerNameMap.get(surgery.getScrubNurseId()));
|
||||
}
|
||||
if (surgery.getApplyDoctorId() != null && surgery.getApplyDoctorName() == null) {
|
||||
surgery.setApplyDoctorName(practitionerNameMap.get(surgery.getApplyDoctorId()));
|
||||
}
|
||||
|
||||
// 填充手术室名称
|
||||
if (surgery.getOperatingRoomId() != null) {
|
||||
if (surgery.getOperatingRoomId() != null && surgery.getOperatingRoomName() == null) {
|
||||
OperatingRoom operatingRoom = operatingRoomService.getById(surgery.getOperatingRoomId());
|
||||
if (operatingRoom != null) {
|
||||
surgery.setOperatingRoomName(operatingRoom.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充执行科室名称
|
||||
if (surgery.getOrgId() != null) {
|
||||
Organization org = organizationService.getById(surgery.getOrgId());
|
||||
if (org != null) {
|
||||
surgery.setOrgName(org.getName());
|
||||
// 使用缓存填充组织名称
|
||||
if (surgery.getOrgId() != null && surgery.getOrgName() == null) {
|
||||
surgery.setOrgName(orgNameMap.get(surgery.getOrgId()));
|
||||
}
|
||||
if (surgery.getApplyDeptId() != null && surgery.getApplyDeptName() == null) {
|
||||
surgery.setApplyDeptName(orgNameMap.get(surgery.getApplyDeptId()));
|
||||
}
|
||||
|
||||
// 填充申请科室名称(如果还没有设置)
|
||||
if (surgery.getApplyDeptId() != null && (surgery.getApplyDeptName() == null || surgery.getApplyDeptName().isEmpty())) {
|
||||
Organization applyDept = organizationService.getById(surgery.getApplyDeptId());
|
||||
if (applyDept != null) {
|
||||
surgery.setApplyDeptName(applyDept.getName());
|
||||
}
|
||||
}
|
||||
|
||||
// 填充申请医生姓名(如果还没有设置) - 使用practitionerId查询Practitioner表
|
||||
if (surgery.getApplyDoctorId() != null && (surgery.getApplyDoctorName() == null || surgery.getApplyDoctorName().isEmpty())) {
|
||||
com.openhis.administration.domain.Practitioner applyDoctor = practitionerService.getById(surgery.getApplyDoctorId());
|
||||
if (applyDoctor != null) {
|
||||
surgery.setApplyDoctorName(applyDoctor.getName());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("填充手术名称字段完成 - patientName: {}, mainSurgeonName: {}, anesthetistName: {}, assistant1Name: {}, assistant2Name: {}, scrubNurseName: {}, operatingRoomName: {}, orgName: {}",
|
||||
surgery.getPatientName(), surgery.getMainSurgeonName(), surgery.getAnesthetistName(), surgery.getAssistant1Name(),
|
||||
surgery.getAssistant2Name(), surgery.getScrubNurseName(), surgery.getOperatingRoomName(), surgery.getOrgName());
|
||||
log.debug("填充手术名称字段完成 - patientName: {}, mainSurgeonName: {}, orgName: {}",
|
||||
surgery.getPatientName(), surgery.getMainSurgeonName(), surgery.getOrgName());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,13 +189,124 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
LEFT JOIN adm_organization apply_dept ON s.apply_dept_id = apply_dept.id
|
||||
</sql>
|
||||
|
||||
<!-- 优化版分页查询:减少JOIN,使用子查询预加载关联数据 -->
|
||||
<select id="getSurgeryPage" parameterType="com.baomidou.mybatisplus.core.conditions.query.QueryWrapper" resultMap="SurgeryResult">
|
||||
<include refid="selectSurgeryVo"/>
|
||||
SELECT
|
||||
s.id,
|
||||
s.surgery_no,
|
||||
s.patient_id,
|
||||
s.patient_name,
|
||||
s.encounter_id,
|
||||
s.apply_doctor_id,
|
||||
s.apply_doctor_name,
|
||||
s.apply_dept_id,
|
||||
s.apply_dept_name,
|
||||
s.surgery_name,
|
||||
s.surgery_code,
|
||||
s.surgery_type_enum,
|
||||
s.surgery_level,
|
||||
s.status_enum,
|
||||
s.planned_time,
|
||||
s.actual_start_time,
|
||||
s.actual_end_time,
|
||||
s.main_surgeon_id,
|
||||
s.main_surgeon_name,
|
||||
s.assistant_1_id,
|
||||
s.assistant_1_name,
|
||||
s.assistant_2_id,
|
||||
s.assistant_2_name,
|
||||
s.anesthetist_id,
|
||||
s.anesthetist_name,
|
||||
s.scrub_nurse_id,
|
||||
s.scrub_nurse_name,
|
||||
s.anesthesia_type_enum,
|
||||
s.body_site,
|
||||
s.incision_level,
|
||||
s.healing_level,
|
||||
s.operating_room_id,
|
||||
s.operating_room_name,
|
||||
s.org_id,
|
||||
s.org_name,
|
||||
s.surgery_indication,
|
||||
s.preoperative_diagnosis,
|
||||
s.postoperative_diagnosis,
|
||||
s.surgery_description,
|
||||
s.postoperative_advice,
|
||||
s.complications,
|
||||
s.surgery_fee,
|
||||
s.anesthesia_fee,
|
||||
s.total_fee,
|
||||
s.remark,
|
||||
s.create_time,
|
||||
s.update_time,
|
||||
s.emergency_flag,
|
||||
s.implant_flag,
|
||||
s.operating_room_confirm_time,
|
||||
s.operating_room_confirm_user,
|
||||
<!-- 患者信息:只查询必要字段 -->
|
||||
p.gender_enum as patient_gender,
|
||||
p.birth_date,
|
||||
<!-- 就诊编号 -->
|
||||
e.bus_no as encounter_no,
|
||||
<!-- 字典文本:使用CASE WHEN避免额外JOIN -->
|
||||
CASE s.surgery_type_enum
|
||||
WHEN 1 THEN '门诊手术'
|
||||
WHEN 2 THEN '住院手术'
|
||||
WHEN 3 THEN '急诊手术'
|
||||
WHEN 4 THEN '择期手术'
|
||||
ELSE '未知'
|
||||
END as surgery_type_enum_dictText,
|
||||
CASE s.surgery_level
|
||||
WHEN 1 THEN '一级手术'
|
||||
WHEN 2 THEN '二级手术'
|
||||
WHEN 3 THEN '三级手术'
|
||||
WHEN 4 THEN '四级手术'
|
||||
WHEN 5 THEN '特级手术'
|
||||
ELSE '未知'
|
||||
END as surgery_level_dictText,
|
||||
CASE s.status_enum
|
||||
WHEN 0 THEN '待排期'
|
||||
WHEN 1 THEN '已排期'
|
||||
WHEN 2 THEN '手术中'
|
||||
WHEN 3 THEN '已完成'
|
||||
WHEN 4 THEN '已取消'
|
||||
WHEN 5 THEN '暂停'
|
||||
ELSE '未知'
|
||||
END as status_enum_dictText,
|
||||
CASE s.anesthesia_type_enum
|
||||
WHEN 0 THEN '无麻醉'
|
||||
WHEN 1 THEN '局部麻醉'
|
||||
WHEN 2 THEN '区域麻醉'
|
||||
WHEN 3 THEN '全身麻醉'
|
||||
WHEN 4 THEN '脊椎麻醉'
|
||||
WHEN 5 THEN '硬膜外麻醉'
|
||||
WHEN 6 THEN '表面麻醉'
|
||||
ELSE '未知'
|
||||
END as anesthesia_type_enum_dictText,
|
||||
CASE s.incision_level
|
||||
WHEN 1 THEN 'I级切口'
|
||||
WHEN 2 THEN 'II级切口'
|
||||
WHEN 3 THEN 'III级切口'
|
||||
WHEN 4 THEN 'IV级切口'
|
||||
ELSE '未知'
|
||||
END as incision_level_dictText,
|
||||
CASE s.healing_level
|
||||
WHEN 1 THEN '甲级愈合'
|
||||
WHEN 2 THEN '乙级愈合'
|
||||
WHEN 3 THEN '丙级愈合'
|
||||
ELSE '未知'
|
||||
END as healing_level_dictText,
|
||||
<!-- 计算年龄 -->
|
||||
EXTRACT(YEAR FROM AGE(p.birth_date)) as patient_age
|
||||
FROM cli_surgery s
|
||||
<!-- 只JOIN必要的表:患者和就诊 -->
|
||||
LEFT JOIN adm_patient p ON s.patient_id = p.id
|
||||
LEFT JOIN adm_encounter e ON s.encounter_id = e.id
|
||||
<where>
|
||||
s.delete_flag = '0'
|
||||
<if test="ew.sqlSegment != null and ew.sqlSegment != ''">
|
||||
<![CDATA[
|
||||
AND ${ew.sqlSegment.replace('tenant_id', 's.tenant_id').replace('create_time', 's.create_time').replace('surgery_no', 's.surgery_no').replace('surgery_name', 's.surgery_name').replace('patient_name', 'p.name').replace('main_surgeon_name', 's.main_surgeon_name').replace('anesthetist_name', 's.anesthetist_name').replace('org_name', 'o.name').replace('status_enum', 's.status_enum').replace('planned_time', 's.planned_time')}
|
||||
AND ${ew.sqlSegment.replace('tenant_id', 's.tenant_id').replace('create_time', 's.create_time').replace('surgery_no', 's.surgery_no').replace('surgery_name', 's.surgery_name').replace('patient_name', 's.patient_name').replace('main_surgeon_name', 's.main_surgeon_name').replace('anesthetist_name', 's.anesthetist_name').replace('org_name', 's.org_name').replace('status_enum', 's.status_enum').replace('planned_time', 's.planned_time')}
|
||||
]]>
|
||||
</if>
|
||||
</where>
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.openhis.web.datadictionary.mapper.ActivityDefinitionManageMapper">
|
||||
<!-- 优化后的分页查询:减少JOIN,使用索引友好的写法 -->
|
||||
<select id="getDiseaseTreatmentPage" parameterType="java.util.Map"
|
||||
resultType="com.openhis.web.datadictionary.dto.DiagnosisTreatmentDto">
|
||||
|
||||
SELECT
|
||||
T3.id,
|
||||
T3.category_code,
|
||||
T3.bus_no,
|
||||
T3.name,
|
||||
T3.py_str,
|
||||
T3.wb_str,
|
||||
T3.type_enum,
|
||||
T3.permitted_unit_code,
|
||||
T3.org_id,
|
||||
T3.location_id,
|
||||
T3.yb_flag,
|
||||
T3.yb_no,
|
||||
T3.yb_match_flag,
|
||||
T3.status_enum,
|
||||
T3.body_site_code,
|
||||
T3.specimen_code,
|
||||
T3.description_text,
|
||||
T3.rule_id,
|
||||
T3.tenant_id,
|
||||
T3.item_type_code,
|
||||
T3.yb_type,
|
||||
T3.price_code,
|
||||
T3.retail_price,
|
||||
T3.maximum_retail_price,
|
||||
T3.chrgitm_lv,
|
||||
T3.children_json,
|
||||
T3.pricing_flag,
|
||||
T3.sort_order,
|
||||
T3.service_range
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
T1.id,
|
||||
T1.category_code,
|
||||
@@ -57,22 +26,26 @@
|
||||
T1.rule_id,
|
||||
T1.tenant_id,
|
||||
T1.chrgitm_lv,
|
||||
T1.children_json,
|
||||
T1.pricing_flag,
|
||||
T1.sort_order,
|
||||
T1.service_range,
|
||||
T2.type_code as item_type_code,
|
||||
T2.yb_type,
|
||||
T2.price_code,
|
||||
T2.price as retail_price,
|
||||
T4.amount as maximum_retail_price,
|
||||
T1.children_json,
|
||||
T1.pricing_flag,
|
||||
T1.sort_order,
|
||||
T1.service_range
|
||||
T4.amount as maximum_retail_price
|
||||
FROM wor_activity_definition T1
|
||||
LEFT JOIN adm_charge_item_definition T2 ON T1.id = T2.instance_id
|
||||
LEFT JOIN adm_charge_item_definition T5 ON T5.instance_id = T1.id AND T5.instance_table = 'wor_activity_definition'
|
||||
LEFT JOIN adm_charge_item_def_detail T4 ON T4.definition_id = T5.id AND T4.condition_code = '4'
|
||||
/* 只JOIN必要的价格表,使用INNER JOIN避免笛卡尔积 */
|
||||
INNER JOIN adm_charge_item_definition T2
|
||||
ON T1.id = T2.instance_id
|
||||
AND T2.instance_table = 'wor_activity_definition'
|
||||
/* 最高零售价使用LEFT JOIN,因为可能不存在 */
|
||||
LEFT JOIN adm_charge_item_def_detail T4
|
||||
ON T4.definition_id = T2.id
|
||||
AND T4.condition_code = '4'
|
||||
<where>
|
||||
T1.delete_flag = '0'
|
||||
AND T2.instance_table = 'wor_activity_definition'
|
||||
<if test="ew.customSqlSegment != null and ew.customSqlSegment != ''">
|
||||
<choose>
|
||||
<when test="ew.customSqlSegment.contains('tenant_id')">
|
||||
@@ -84,9 +57,7 @@
|
||||
</choose>
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY T1.bus_no DESC
|
||||
) T3
|
||||
|
||||
ORDER BY T1.id DESC
|
||||
</select>
|
||||
|
||||
<select id="getDiseaseTreatmentOne" resultType="com.openhis.web.datadictionary.dto.DiagnosisTreatmentDto">
|
||||
|
||||
@@ -22,7 +22,7 @@ axios.defaults.headers['Request-Method-Name'] = 'login'
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: 60000,
|
||||
timeout: 120000, // 增加到120秒,配合后端超时设置
|
||||
// 新增:重写响应解析逻辑,大数字自动转字符串(移到这里!)
|
||||
transformResponse: [
|
||||
function (data) {
|
||||
|
||||
@@ -556,6 +556,10 @@ const anesthesiaNameList = ref([])
|
||||
const surgeryLoading = ref(false)
|
||||
const anesthesiaLoading = ref(false)
|
||||
|
||||
// 防抖定时器
|
||||
let surgerySearchTimer = null
|
||||
let anesthesiaSearchTimer = null
|
||||
|
||||
// 计算总费用
|
||||
const totalCalculatedFee = computed(() => {
|
||||
const surgeryFee = parseFloat(form.value.surgeryFee || 0)
|
||||
@@ -868,8 +872,18 @@ function handleRefresh() {
|
||||
proxy.$modal.msgSuccess('刷新成功')
|
||||
}
|
||||
|
||||
// 远程搜索手术项目
|
||||
// 远程搜索手术项目(添加300ms防抖)
|
||||
function remoteSearchSurgery(query) {
|
||||
if (surgerySearchTimer) {
|
||||
clearTimeout(surgerySearchTimer)
|
||||
}
|
||||
surgerySearchTimer = setTimeout(() => {
|
||||
doSearchSurgery(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 实际执行手术搜索
|
||||
function doSearchSurgery(query) {
|
||||
surgeryLoading.value = true
|
||||
getDiagnosisTreatmentList({
|
||||
searchKey: query || '',
|
||||
@@ -900,8 +914,18 @@ function remoteSearchSurgery(query) {
|
||||
})
|
||||
}
|
||||
|
||||
// 远程搜索麻醉项目
|
||||
// 远程搜索麻醉项目(添加300ms防抖)
|
||||
function remoteSearchAnesthesia(query) {
|
||||
if (anesthesiaSearchTimer) {
|
||||
clearTimeout(anesthesiaSearchTimer)
|
||||
}
|
||||
anesthesiaSearchTimer = setTimeout(() => {
|
||||
doSearchAnesthesia(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 实际执行麻醉搜索
|
||||
function doSearchAnesthesia(query) {
|
||||
anesthesiaLoading.value = true
|
||||
getDiagnosisTreatmentList({
|
||||
searchKey: query || '',
|
||||
|
||||
@@ -58,7 +58,7 @@ export function submitApproval(busNo) {
|
||||
return request({
|
||||
url: '/inventory-manage/purchase/submit-approval',
|
||||
method: 'put',
|
||||
data: busNo
|
||||
data: { busNo: busNo }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function withdrawApproval(busNo) {
|
||||
return request({
|
||||
url: '/inventory-manage/purchase/withdraw-approval',
|
||||
method: 'put',
|
||||
data: busNo
|
||||
data: { busNo: busNo }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export function submitApprovalReturn(busNo) {
|
||||
return request({
|
||||
url: '/inventory-manage/return/return-submit-approval',
|
||||
method: 'put',
|
||||
data: busNo
|
||||
data: { busNo: busNo }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user