From 10b519082e4961012ef4c7a27187bbb4698cf266 Mon Sep 17 00:00:00 2001 From: huxuejian Date: Wed, 12 Aug 2026 15:20:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.dev | 52 ----- .env.pro | 57 +---- hlw/consult-order/index.js | 47 +++- hlw/consult-order/trigger.js | 2 +- hlw/drug-access-level.js | 66 ++++++ hlw/drug-access-level.test.js | 56 +++++ hlw/drug-info/index.js | 8 +- hlw/drug-info/verify.js | 2 + hlw/hlw-doctor/hlw-people-utils.js | 8 + hlw/hlw-doctor/index.js | 17 +- hlw/index.js | 6 + hlw/online-drug-info/index.js | 6 + hlw/self-auth/index.js | 332 +++++++++++++++++++++++++++++ hlw/self-auth/index.test.js | 282 ++++++++++++++++++++++++ 14 files changed, 826 insertions(+), 115 deletions(-) create mode 100644 hlw/drug-access-level.js create mode 100644 hlw/drug-access-level.test.js create mode 100644 hlw/self-auth/index.js create mode 100644 hlw/self-auth/index.test.js diff --git a/.env.dev b/.env.dev index fbd090e..f9e2122 100644 --- a/.env.dev +++ b/.env.dev @@ -22,58 +22,6 @@ CONFIG_ALIPAY_MINI_APPID = 2021004129669493 CONFIG_ALIPAY_MINI_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo7Ulswxr/3dJpX0RrPylV9rR3STdwGtcbV4ppNEiR0R37/9L/JpO/Y5gbEaaPn9S/xALzGKwI4N3/v1H2x566LJIQvzh5szCviy20NNGc+TbyxLJ9gLBxcsBDT8+o+a8CdaHbU/xvutZvLRa34D5pa6lcDCONC6q8flwWmXemUZ5widtW10F+4Niyufr+8hKkIpRfQMWYbsuA3EpXwjhztJglxPnjMN7r33vq9Pd5cU+KOOGuelXdB8aMwWcaXbffb7OKaTtZYnbUezJKbqZ+LtYgGF8+E5CKP7tzlEnZvQZwlPgS516EG3WRcCqjnEYO7RpG/eZ/0569d4TW8UPrwIDAQAB CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCirWNE4gjlUOV6+PNo6CPyLUnJOe/yw8ODZInBun6P2h3rs33WBLFBGdbV9Ct5pXXou48l2icKPsFAWVI5MmtZUIHGBkdvxzQeLNeKpKlJvpujWS/oLQq232MeM7YmWa4tjr40SHqlhVzxcgYST55jBXmVo9KJNrBj5i5zlR9N49x5EJZrUXsVUprsavk1z1JJyUKthw/FrycGsS47YQ5jHyQUFsqEviyOY5Bgz4qZ4xhHJ7p7zhwkiXHqscyeF0+Eu1xf4jS23jlbOiV+5yPo4eWcHElf0aD+k+ngPVpgVqNHMYBTSW1u8pFCpUVDv02QUNg1NRJpBcTAtnLY7yyhAgMBAAECggEAKl54ZV9tq7QnIKEoXGFhw1iSSCuj2BmumVEdI+zbu8G2W0YzdwNf2zHMp4rhq9wxWn1HKjeoRV3U3VqmNKlyFiAGvLmz72P1qgpJ1R1Pzhor1R6MgK8ChGCoIxyay2A+IjV0So+6CdQzQdtNw4UjHm01noi9c+lfhZVU8dub/J/62cGCfcBh/NX+juyI+5Vb9BLnG0CbFZ1SbssK8FN9w6MiKNq/fgIdMyvZXWt9Zoj21Bc9eyxd0EUPp800P6eeThzwNVyeSOETehIDSGwndrbxbLX1nCmievnEYZ/pT6T0J+UuPkdLjZq+VQQry0IxAlVvRim33JS4w99gBYIHcQKBgQDSQsEL5QtSLNg3xs5R3vMG+C8wJbm0eYiPlpSLcFgLxedoxRPdjzeT/Wfi245mA1L8NADRn37gfgPukc5TgWzTV340KzlScAv0Y2HHiQV9HII6b5I/f2S3AsM2IY4jDN+PryBDT5mTYUwW7LhtxC97LqCozPhY6aPozoD6VKBhFQKBgQDGEMAcrPSmXNjxhg/1lPJoe7fe4A3uyLPfHIVqZTFSxrfzwteL3bMuhsx8eSNnmT35trQfKXYT8Pz85IWGcoBPuhDkrjDQy+w5u4QeRcyJQRyUqtDxruIwNbvN/EL6FME24u6bIng6FjHW+0j2SFrFIWpUo1v3B5mSRGzvTWZIXQKBgAXI1lzIaV5SY4RtUMHJ22/Gpi2x7AD+Eva4Us/fQFzajfpA6cH/razBnPkNx0YhM0hC+f7KX4g3j04h7sY3WlIaJxuyULpm4L1ReqAtsdv2S9LlvOVIUPID/nDfzXRDZ2chj0aqyvPWCmzbCFvkuNWvu7XJJc/0ZZEDFWNNrrfdAoGAB/2dAosnw55zUisqcan0rapwHJnFdiC6wQGRkF1MxB4oRblSoN3s4K+niE4K058nFO/+DFPL47oROtKtYGM2MgE7tT4h0gqoGRwrlF0Uh2UNFiJeE6IOlgtU7FaFDTnRIbm+DpwxZYaHtDinFOiOYO2cMJX2m9y+oVwJWQ4yVjECgYEAg01aEYSOYQVeeub8g1e1wHKj3L4v/xECpZNzoQWF/WhZDHiKCqN2VD54XYl0dUHFLqBXn01tfg0gt4kO/roiWJdH273DBYu52ttwNdcRVlud6RlTBDO//M8dglrxOWz51HhSTepOXmJflF1dept9OPVddzQAJdFN65umF27DIVI= -## 蜂鸟即时配送请求域名 -CONFIG_FENGNIAO_API_DOMAIN=https://exam-anubis.ele.me -## 蜂鸟即时配送分配的 appId 和 appSecret -CONFIG_FENGNIAO_APPID=2711175259951256784 -CONFIG_FENGNIAO_SECRET=744c587a-61eb-4dc5-a226-e88b7f7b15d0 -## 蜂鸟即时配送 后台管理页面 -> 应用管理 -> 查看沙箱环境 -> 商户信息的商户ID 测试(15649332) | 正式(15549684) -CONFIG_FENGNIAO_MERCHANT_ID=15649332 -## 蜂鸟即时配送 后台管理页面 -> 应用管理 -> 查看沙箱环境 -> 门店信息的门店编码 测试(testorder001) | 正式(ZY108) -CONFIG_FENGNIAO_OUT_SHOP_CODE=testorder001 -## 蜂鸟即时配送 商家授权回调地址 -CONFIG_FENGNIAO_AUTH_URL=https://patient.youcan365.com/zyt/fengniao-auth - - -## (邮乐)邮政快递 测试环境请求域名、分配的orgId、AES加密秘钥以及IV -CONFIG_EMS_API_DOMAIN=https://topup.beta.ule.com -## (邮乐)邮政快递 测试环境分配的orgId、AES加密秘钥以及IV -CONFIG_EMS_ORGID=900004 -CONFIG_EMS_AES_Key=HOKAwvtyPe9Atve4 -CONFIG_EMS_AES_IV=HOKAwvtyPe9Atve4 -## (邮乐)邮政快递 邮乐分享用户ID (接口文档 2.1订单数据回传接口的入参uleShareUserId字段值, 由对方技术人员提供) -CONFIG_EMS_SHARE_USER_ID=yzg10000050008 - -## 邮政 请求地址 -CONFIG_EMS_API_URL=https://api.ems.com.cn/amp-prod-api/f/amp/api/test -## 邮政 协议用户编码 -CONFIG_EMS_USER_CODE=NNLXIQRYV -## 邮政 测试授权码 -CONFIG_EMS_AUTH_CODE=U1pPOYv6VBULDah6 -## 邮政 测试签名钥匙 -CONFIG_EMS_SIGN_SECRET=ZmVRRjkyeDZnWHFnUGxkRQ== -## 邮政 协议客户号 -CONFIG_EMS_SENDER_NO=1100241354944 - - ## 海宁his api CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026 -## 银联支付请求基础路径 -CONFIG_UNION_PAY_BASE_URL=https://test-api-open.chinaums.com/v1 -## 银联支付分配的appId -CONFIG_UNION_PAY_APPID=10037ca764636bbc01647d1ef4e10009 -## 银联支付分配的appKey -CONFIG_UNION_PAY_APPKEY=389d830dcc0c46a58b0ea1962041e497 -## 银联支付分配的商户号 -CONFIG_UNION_PAY_MID=898201612345678 -## 银联支付分配的终端号 -CONFIG_UNION_PAY_TID=88880001 -## 银联支付回调地址 -CONFIG_UNION_PAY_NOTIFY=https://patient.youcan365.com/zyt/union-pay-notify -## 银联支付金额 -CONFIG_DEV_UNION_PAY_AMOUNT=0.02 - -CONFIG_SKIP_SMS_VERIFY_CODE=YES - - diff --git a/.env.pro b/.env.pro index 9794788..fcb11a4 100644 --- a/.env.pro +++ b/.env.pro @@ -1,5 +1,5 @@ -CONFIG_DB_USERNAME=zytAdmin -CONFIG_DB_PASSWORD=Lie89763!Zhy +CONFIG_DB_USERNAME=hnhlwYyAdmin +CONFIG_DB_PASSWORD=Lie89763!HnhlwYy CONFIG_DB_HOST=192.168.0.4 CONFIG_DB_PORT=27017 CONFIG_NODE_ENV=production @@ -13,57 +13,8 @@ CONFIG_ZYB_YB_APPID=7D8D8E29924048539165F5E2E08C4616 CONFIG_ZYB_YB_SECRET=77F9610253B94263BCF8DBB61BF90D92 CONFIG_SMS_GATEWAY_DISABLED=false -## 支付宝回调地址 -CONFIG_ALIPAY_PAY_NOTIFY=https://hlw.zytzymz.com/alipay-pay-notify ## 支付宝小程序appId CONFIG_ALIPAY_MINI_APPID = 2021005110656858 +CONFIG_ALIPAY_MINI_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo7Ulswxr/3dJpX0RrPylV9rR3STdwGtcbV4ppNEiR0R37/9L/JpO/Y5gbEaaPn9S/xALzGKwI4N3/v1H2x566LJIQvzh5szCviy20NNGc+TbyxLJ9gLBxcsBDT8+o+a8CdaHbU/xvutZvLRa34D5pa6lcDCONC6q8flwWmXemUZ5widtW10F+4Niyufr+8hKkIpRfQMWYbsuA3EpXwjhztJglxPnjMN7r33vq9Pd5cU+KOOGuelXdB8aMwWcaXbffb7OKaTtZYnbUezJKbqZ+LtYgGF8+E5CKP7tzlEnZvQZwlPgS516EG3WRcCqjnEYO7RpG/eZ/0569d4TW8UPrwIDAQAB +CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCirWNE4gjlUOV6+PNo6CPyLUnJOe/yw8ODZInBun6P2h3rs33WBLFBGdbV9Ct5pXXou48l2icKPsFAWVI5MmtZUIHGBkdvxzQeLNeKpKlJvpujWS/oLQq232MeM7YmWa4tjr40SHqlhVzxcgYST55jBXmVo9KJNrBj5i5zlR9N49x5EJZrUXsVUprsavk1z1JJyUKthw/FrycGsS47YQ5jHyQUFsqEviyOY5Bgz4qZ4xhHJ7p7zhwkiXHqscyeF0+Eu1xf4jS23jlbOiV+5yPo4eWcHElf0aD+k+ngPVpgVqNHMYBTSW1u8pFCpUVDv02QUNg1NRJpBcTAtnLY7yyhAgMBAAECggEAKl54ZV9tq7QnIKEoXGFhw1iSSCuj2BmumVEdI+zbu8G2W0YzdwNf2zHMp4rhq9wxWn1HKjeoRV3U3VqmNKlyFiAGvLmz72P1qgpJ1R1Pzhor1R6MgK8ChGCoIxyay2A+IjV0So+6CdQzQdtNw4UjHm01noi9c+lfhZVU8dub/J/62cGCfcBh/NX+juyI+5Vb9BLnG0CbFZ1SbssK8FN9w6MiKNq/fgIdMyvZXWt9Zoj21Bc9eyxd0EUPp800P6eeThzwNVyeSOETehIDSGwndrbxbLX1nCmievnEYZ/pT6T0J+UuPkdLjZq+VQQry0IxAlVvRim33JS4w99gBYIHcQKBgQDSQsEL5QtSLNg3xs5R3vMG+C8wJbm0eYiPlpSLcFgLxedoxRPdjzeT/Wfi245mA1L8NADRn37gfgPukc5TgWzTV340KzlScAv0Y2HHiQV9HII6b5I/f2S3AsM2IY4jDN+PryBDT5mTYUwW7LhtxC97LqCozPhY6aPozoD6VKBhFQKBgQDGEMAcrPSmXNjxhg/1lPJoe7fe4A3uyLPfHIVqZTFSxrfzwteL3bMuhsx8eSNnmT35trQfKXYT8Pz85IWGcoBPuhDkrjDQy+w5u4QeRcyJQRyUqtDxruIwNbvN/EL6FME24u6bIng6FjHW+0j2SFrFIWpUo1v3B5mSRGzvTWZIXQKBgAXI1lzIaV5SY4RtUMHJ22/Gpi2x7AD+Eva4Us/fQFzajfpA6cH/razBnPkNx0YhM0hC+f7KX4g3j04h7sY3WlIaJxuyULpm4L1ReqAtsdv2S9LlvOVIUPID/nDfzXRDZ2chj0aqyvPWCmzbCFvkuNWvu7XJJc/0ZZEDFWNNrrfdAoGAB/2dAosnw55zUisqcan0rapwHJnFdiC6wQGRkF1MxB4oRblSoN3s4K+niE4K058nFO/+DFPL47oROtKtYGM2MgE7tT4h0gqoGRwrlF0Uh2UNFiJeE6IOlgtU7FaFDTnRIbm+DpwxZYaHtDinFOiOYO2cMJX2m9y+oVwJWQ4yVjECgYEAg01aEYSOYQVeeub8g1e1wHKj3L4v/xECpZNzoQWF/WhZDHiKCqN2VD54XYl0dUHFLqBXn01tfg0gt4kO/roiWJdH273DBYu52ttwNdcRVlud6RlTBDO//M8dglrxOWz51HhSTepOXmJflF1dept9OPVddzQAJdFN65umF27DIVI= -## 蜂鸟即时配送请求域名 -CONFIG_FENGNIAO_API_DOMAIN=https://open-anubis.ele.me -## 蜂鸟即时配送分配的 appId 和 appSecret -CONFIG_FENGNIAO_APPID=2711175259951256784 -CONFIG_FENGNIAO_SECRET=744c587a-61eb-4dc5-a226-e88b7f7b15d0 -## 蜂鸟即时配送 后台管理页面 -> 应用管理 -> 查看沙箱环境 -> 商户信息的商户ID (生产环境目前未提供) -CONFIG_FENGNIAO_MERCHANT_ID=15549684 -## 蜂鸟即时配送 后台管理页面 -> 应用管理 -> 查看沙箱环境 -> 门店信息的门店编码 (生产环境目前未提供) -CONFIG_FENGNIAO_OUT_SHOP_CODE=ZY108 -## 蜂鸟即时配送 商家授权回调地址 -CONFIG_FENGNIAO_AUTH_URL=https://hlw.zytzymz.com/fengniao-auth - -## 邮政快递 测试环境请求域名、分配的orgId、AES加密秘钥以及IV -CONFIG_EMS_API_DOMAIN=https://topup.ule.com -## 邮政快递 测试环境分配的orgId、AES加密秘钥以及IV -CONFIG_EMS_ORGID=900004 -CONFIG_EMS_AES_Key=HOKAwvtyPe9Atve4 -CONFIG_EMS_AES_IV=HOKAwvtyPe9Atve4 -## 邮政快递 邮乐分享用户ID (接口文档 2.1订单数据回传接口的入参uleShareUserId字段值, 由对方技术人员提供) -CONFIG_EMS_SHARE_USER_ID=yzg10000050008 - - -## 邮政 请求地址 -CONFIG_EMS_API_URL=https://api.ems.com.cn/amp-prod-api/f/amp/api/open -## 邮政 协议用户编码 -CONFIG_EMS_USER_CODE=NNLXIQRYV -## 邮政 生产授权码 -CONFIG_EMS_AUTH_CODE=SKtohTy8lN43fJKH -## 邮政 生产签名钥匙 -CONFIG_EMS_SIGN_SECRET=OGdLZ1ZIN0VJVTN6c1RSeA== -## 邮政 协议客户号 -CONFIG_EMS_SENDER_NO=1100241354944 - - -## 震元his api -CONFIG_ZYT_HIS_BASE_URL=http://115.239.161.48:8083 - -## 银联支付请求基础路径 -CONFIG_UNION_PAY_BASE_URL=https://api-mop.chinaums.com/v1 -## 银联支付分配的appId -CONFIG_UNION_PAY_APPID=8a9481d09c055fef019d6755a0120ca8 -## 银联支付分配的appKey -CONFIG_UNION_PAY_APPKEY=790709c7a7e444c8b7e24688eb24f54a -## 银联支付分配的商户号 -CONFIG_UNION_PAY_MID=89833068011004Y -## 银联支付分配的终端号 -CONFIG_UNION_PAY_TID=15K1FZZN -## 银联支付回调地址 -CONFIG_UNION_PAY_NOTIFY=https://hlw.zytzymz.com/union-pay-notify diff --git a/hlw/consult-order/index.js b/hlw/consult-order/index.js index e56267e..6204715 100644 --- a/hlw/consult-order/index.js +++ b/hlw/consult-order/index.js @@ -7,6 +7,9 @@ const trigger = require("./trigger"); const { ObjectId } = require("mongodb"); const timeApi = require("../hlw-time-duration"); const { encryptOrderFields, decryptOrderFields, decryptOrderList } = require("./format"); +const { getRequiredDrugAccessLevels, doctorMeetsDrugAccessLevels } = require("../drug-access-level"); + +const doctorOrPharmacistOfflineMessage = "当前医生或药师不在线,暂无法提交问诊单,请稍后再试"; const hisPayStatus = { 0: "已支付", @@ -155,7 +158,8 @@ async function addConsultOrder(item) { async function addOnlineConsultOrder(params) { try { - params.registerId = common.generateUniqueStr('GH'); + params.registerId = await generateRegisterId(); + const requiredDrugAccessLevels = await getRequiredDrugAccessLevels(db, params.drugs); const storeApi = require("../drug-store"); const { success, list, message } = await storeApi( { @@ -195,14 +199,19 @@ async function addOnlineConsultOrder(params) { deptName: 1, deptCode: 1, onlineStatus: 1, + antibiotic_level: 1, + tumor_use: 1, }, } ); if (!doctor) { - return { success: false, message: "未查询到医生" }; + return { success: false, message: doctorOrPharmacistOfflineMessage }; } if (doctor.onlineStatus !== "online") { - return { success: false, message: "医生未在线, 请重新选择医生" }; + return { success: false, message: doctorOrPharmacistOfflineMessage }; + } + if (!doctorMeetsDrugAccessLevels(doctor, requiredDrugAccessLevels)) { + return { success: false, message: doctorOrPharmacistOfflineMessage }; } params.doctorName = doctor.doctorName || ""; params.doctorTitle = doctor.title || ""; @@ -212,7 +221,12 @@ async function addOnlineConsultOrder(params) { } else { // 随机选取一位医生 (单量少的权重大) const doctorApi = require("../hlw-doctor"); - const data = await doctorApi({ type: "getOptimalRecommendDoctor", doctorType: 'west' }, db); + const data = await doctorApi({ + type: "getOptimalRecommendDoctor", + doctorType: 'west', + minimumAntibioticLevel: requiredDrugAccessLevels.antibioticLevel, + minimumTumorUse: requiredDrugAccessLevels.tumorUse, + }, db); if (data.success && data.data) { params.doctorName = data.data.doctorName || ""; params.doctorCode = data.data.doctorNo; @@ -220,12 +234,12 @@ async function addOnlineConsultOrder(params) { params.deptName = data.data.deptName || ""; params.unitCode = data.data.deptCode || ""; } else { - return { success: false, message: data.message }; + return { success: false, message: doctorOrPharmacistOfflineMessage }; } } const onlineYaoshiCount = await db.collection("hlw-doctor").countDocuments({ corpId: params.corpId, onlineStatus: "online", job: "pharmacist" }); if (onlineYaoshiCount === 0) { - return { success: false, message: "当前医生或药师不在线,暂无法提交问诊单,请稍后再试" }; + return { success: false, message: doctorOrPharmacistOfflineMessage }; } const imAccountResult = await tencentIM({ type: "importAccount", @@ -2345,3 +2359,24 @@ async function autoHandleExpireOrder() { return { success: false, message: e.message || "操作失败" }; } } + + +async function generateRegisterId(db, type = 'consult-register-id') { + const key = dayjs().format('YYYY-MM-DD'); + const day = dayjs().format('YYMMDD') + const collection = db.collection('sequence-counter'); + + const result = await collection.findOneAndUpdate( + { key, type }, // 添加 type 字段区分不同场景 + { + $inc: { sequence: 1 }, + $setOnInsert: { key, type } + }, + { + upsert: true, + returnDocument: 'after' + } + ); + const sequence = result.sequence; + return `${day}${String(sequence).padStart(4, '0')}`; +} diff --git a/hlw/consult-order/trigger.js b/hlw/consult-order/trigger.js index bc19855..b20b588 100644 --- a/hlw/consult-order/trigger.js +++ b/hlw/consult-order/trigger.js @@ -53,7 +53,7 @@ async function triggerDelayedTask({ triggerTaskId }) { let reason = `本次问诊已取消,取消原因:${ orderStatus === "pending" ? "超时未接诊" : "超时未处理" }。${ - orderSource === "MATEPAD" ? "请联系线下退费。" : "费用将于48小时内退还您的支付账户。" + orderSource === "MATEPAD" ? "请联系线下退费。" : "" }`; await consultOrder( { diff --git a/hlw/drug-access-level.js b/hlw/drug-access-level.js new file mode 100644 index 0000000..3ca9a59 --- /dev/null +++ b/hlw/drug-access-level.js @@ -0,0 +1,66 @@ +const { ObjectId } = require("mongodb"); + +const ACCESS_LEVEL_MIN = 0; +const ACCESS_LEVEL_MAX = 3; + +function normalizeAccessLevel(value) { + return Number.isInteger(value) && value >= ACCESS_LEVEL_MIN && value <= ACCESS_LEVEL_MAX + ? value + : ACCESS_LEVEL_MIN; +} + +function getHighestDrugAccessLevels(drugs = []) { + return drugs.reduce( + (levels, drug) => ({ + antibioticLevel: Math.max(levels.antibioticLevel, normalizeAccessLevel(drug && drug.antibiotic_level)), + tumorUse: Math.max(levels.tumorUse, normalizeAccessLevel(drug && drug.tumor_use)), + }), + { antibioticLevel: ACCESS_LEVEL_MIN, tumorUse: ACCESS_LEVEL_MIN } + ); +} + +async function getRequiredDrugAccessLevels(db, drugs) { + const drugIds = Array.isArray(drugs) + ? [...new Set(drugs + .map(drug => drug && drug._id) + .filter(id => ObjectId.isValid(id)) + .map(id => String(id)))] + : []; + + if (drugIds.length === 0) { + return getHighestDrugAccessLevels([]); + } + + const drugList = await db.collection("drug-info").find( + { _id: { $in: drugIds.map(id => new ObjectId(id)) } }, + { projection: { antibiotic_level: 1, tumor_use: 1 } } + ).toArray(); + + return getHighestDrugAccessLevels(drugList); +} + +function doctorMeetsDrugAccessLevels(doctor, requiredLevels) { + return normalizeAccessLevel(doctor && doctor.antibiotic_level) >= normalizeAccessLevel(requiredLevels && requiredLevels.antibioticLevel) + && normalizeAccessLevel(doctor && doctor.tumor_use) >= normalizeAccessLevel(requiredLevels && requiredLevels.tumorUse); +} + +function getDoctorAccessLevelQuery(minimumAntibioticLevel, minimumTumorUse) { + const query = {}; + const antibioticLevel = normalizeAccessLevel(minimumAntibioticLevel); + const tumorUse = normalizeAccessLevel(minimumTumorUse); + if (antibioticLevel > 0) { + query.antibiotic_level = { $gte: antibioticLevel }; + } + if (tumorUse > 0) { + query.tumor_use = { $gte: tumorUse }; + } + return query; +} + +module.exports = { + normalizeAccessLevel, + getHighestDrugAccessLevels, + getRequiredDrugAccessLevels, + doctorMeetsDrugAccessLevels, + getDoctorAccessLevelQuery, +}; diff --git a/hlw/drug-access-level.test.js b/hlw/drug-access-level.test.js new file mode 100644 index 0000000..a923a81 --- /dev/null +++ b/hlw/drug-access-level.test.js @@ -0,0 +1,56 @@ +const { ObjectId } = require("mongodb"); +const { + normalizeAccessLevel, + getHighestDrugAccessLevels, + getRequiredDrugAccessLevels, + doctorMeetsDrugAccessLevels, + getDoctorAccessLevelQuery, +} = require("./drug-access-level"); + +describe("drug access levels", () => { + test("normalizes missing and invalid values to zero", () => { + expect([undefined, null, "2", -1, 4].map(normalizeAccessLevel)).toEqual([0, 0, 0, 0, 0]); + expect([0, 1, 2, 3].map(normalizeAccessLevel)).toEqual([0, 1, 2, 3]); + }); + + test("calculates the independent highest levels", () => { + expect(getHighestDrugAccessLevels([ + { antibiotic_level: 3, tumor_use: 1 }, + { antibiotic_level: 1, tumor_use: 2 }, + { antibiotic_level: 99 }, + ])).toEqual({ antibioticLevel: 3, tumorUse: 2 }); + expect(getHighestDrugAccessLevels([])).toEqual({ antibioticLevel: 0, tumorUse: 0 }); + }); + + test("queries valid drug ids and treats missing records as zero", async () => { + const validId = new ObjectId(); + const toArray = jest.fn().mockResolvedValue([{ antibiotic_level: 2 }]); + const find = jest.fn().mockReturnValue({ toArray }); + const db = { collection: jest.fn().mockReturnValue({ find }) }; + + await expect(getRequiredDrugAccessLevels(db, [ + { _id: validId.toString() }, + { _id: "invalid" }, + { _id: validId.toString() }, + ])).resolves.toEqual({ antibioticLevel: 2, tumorUse: 0 }); + expect(find.mock.calls[0][0]._id.$in).toHaveLength(1); + expect(find.mock.calls[0][1]).toEqual({ projection: { antibiotic_level: 1, tumor_use: 1 } }); + }); + + test("requires doctors to meet both levels and treats missing fields as zero", () => { + const required = { antibioticLevel: 2, tumorUse: 1 }; + expect(doctorMeetsDrugAccessLevels({ antibiotic_level: 2, tumor_use: 3 }, required)).toBe(true); + expect(doctorMeetsDrugAccessLevels({ antibiotic_level: 1, tumor_use: 3 }, required)).toBe(false); + expect(doctorMeetsDrugAccessLevels({}, { antibioticLevel: 0, tumorUse: 0 })).toBe(true); + expect(doctorMeetsDrugAccessLevels({}, { antibioticLevel: 1, tumorUse: 0 })).toBe(false); + }); + + test("adds minimum-level query conditions without changing legacy calls", () => { + expect(getDoctorAccessLevelQuery()).toEqual({}); + expect(getDoctorAccessLevelQuery(0, 0)).toEqual({}); + expect(getDoctorAccessLevelQuery(2, 3)).toEqual({ + antibiotic_level: { $gte: 2 }, + tumor_use: { $gte: 3 }, + }); + }); +}); diff --git a/hlw/drug-info/index.js b/hlw/drug-info/index.js index 2c746a0..19553bd 100644 --- a/hlw/drug-info/index.js +++ b/hlw/drug-info/index.js @@ -239,6 +239,12 @@ async function addHlwDrugInfo(ctx) { if (diagnosisIds.present) { newDrug.applicableDiagnosisIds = diagnosisIds.value; } + if (!Object.prototype.hasOwnProperty.call(newDrug, "antibiotic_level")) { + newDrug.antibiotic_level = 0; + } + if (!Object.prototype.hasOwnProperty.call(newDrug, "tumor_use")) { + newDrug.tumor_use = 0; + } newDrug.createTime = Date.now(); const res = await db.collection("drug-info").insertOne(newDrug); return { success: true, message: "新增药品成功", id: res.insertedId } @@ -454,4 +460,4 @@ async function getDrugMatchedDisease(ctx) { } catch (e) { return { success: false, message: e.message } } -} \ No newline at end of file +} diff --git a/hlw/drug-info/verify.js b/hlw/drug-info/verify.js index 8e5c2d6..766e5e6 100644 --- a/hlw/drug-info/verify.js +++ b/hlw/drug-info/verify.js @@ -20,6 +20,8 @@ const drugFieldDict = [ { name: '药库货品ID', field: 'product_id', libType: 'online', verify: (val, label) => validateNumber(val, label) }, { name: '通用名', field: 'generic_name', verify: (val, label) => validator.verifyString(val, label) }, { name: '通用名拼音码', field: 'generic_pinyin', verify: (val, label) => validator.verifyString(val, label) }, + { name: '抗生素级别', field: 'antibiotic_level', verify: (val, label) => validator.verifyListItem(val, [0, 1, 2, 3], false, label, 0) }, + { name: '肿瘤使用', field: 'tumor_use', verify: (val, label) => validator.verifyListItem(val, [0, 1, 2, 3], false, label, 0) }, // 仅在线药品库使用:管理分类(处方药 / OTC)与剂型 { name: '管理分类', field: 'manage_category', verify: (val, label) => validator.verifyString(val, label) }, { name: '剂型', field: 'dosage_form', verify: (val, label) => validator.verifyString(val, label) }, diff --git a/hlw/hlw-doctor/hlw-people-utils.js b/hlw/hlw-doctor/hlw-people-utils.js index fd2363c..e95c03d 100644 --- a/hlw/hlw-doctor/hlw-people-utils.js +++ b/hlw/hlw-doctor/hlw-people-utils.js @@ -52,6 +52,14 @@ const rules = [ key: 'title', validate: val => validator.verifyString(val, '职称', 0, 30) }, + { + key: 'antibiotic_level', + validate: val => validator.verifyListItem(val, [0, 1, 2, 3], false, '抗生素级别', 0) + }, + { + key: 'tumor_use', + validate: val => validator.verifyListItem(val, [0, 1, 2, 3], false, '肿瘤使用', 0) + }, { key: 'intro', validate: val => validator.verifyString(val, '个人介绍', 0, 300) diff --git a/hlw/hlw-doctor/index.js b/hlw/hlw-doctor/index.js index 1c26d54..068ed17 100644 --- a/hlw/hlw-doctor/index.js +++ b/hlw/hlw-doctor/index.js @@ -2,6 +2,7 @@ const { ObjectId } = require("mongodb"); const dayjs = require("dayjs"); const peopleUtils = require("./hlw-people-utils"); const { recordDoctorRecommendation, getDoctorRecommendationCount } = require("./doctor-count-in-one-minute"); +const { getDoctorAccessLevelQuery } = require("../drug-access-level"); let db = ""; module.exports = async (item, mongodb) => { db = mongodb; @@ -133,7 +134,12 @@ async function getRecommendDoctor({ storeCode, presType }) { * @param {boolean} recordInOneMinute 是否记录一分钟内获取推荐医生的数量 * @returns */ -async function getOptimalRecommendDoctor({ recordInOneMinute = false, doctorType }) { +async function getOptimalRecommendDoctor({ + recordInOneMinute = false, + doctorType, + minimumAntibioticLevel, + minimumTumorUse, +}) { try { const type = ['west', 'chinese'].includes(doctorType) ? doctorType : 'west'; // 查询条件:在线且服务状态启用的医生 @@ -148,6 +154,7 @@ async function getOptimalRecommendDoctor({ recordInOneMinute = false, doctorType } else if (type === 'west') { query.openWestPres = true; } + Object.assign(query, getDoctorAccessLevelQuery(minimumAntibioticLevel, minimumTumorUse)); const projection = { doctorNo: 1, doctorName: 1, @@ -157,6 +164,8 @@ async function getOptimalRecommendDoctor({ recordInOneMinute = false, doctorType deptCode: 1, onlineStatus: 1, serviceStatus: 1, + antibiotic_level: 1, + tumor_use: 1, } const doctorList = await db.collection("hlw-doctor").find(query, { projection }).toArray(); if (doctorList.length === 0) { @@ -657,6 +666,10 @@ async function addHlwPerson(ctx) { if (total > 0) { return { success: false, message: "院内工号已存在" } } + if (data.job === 'doctor') { + data.antibiotic_level = data.antibiotic_level ?? 0; + data.tumor_use = data.tumor_use ?? 0; + } const res = await db.collection("hlw-doctor").insertOne({ ...data, corpId, @@ -812,4 +825,4 @@ async function getDoctorByNo(ctx) { message: err.message || "查询失败", }; } -} \ No newline at end of file +} diff --git a/hlw/index.js b/hlw/index.js index 32b367b..315a867 100644 --- a/hlw/index.js +++ b/hlw/index.js @@ -28,6 +28,7 @@ const chineseMedicine = require("./chinese-medicine"); const chineseMedicineOrder = require("./chinese-medicine-order"); const hlwPatient = require("./hlw-patient"); const scannedCardArchive = require("./scanned-card-archive"); +const selfAuth = require("./self-auth"); module.exports = async (item, db, context) => { switch (item.type) { @@ -273,6 +274,11 @@ module.exports = async (item, db, context) => { case "saveScannedCard": case "getScannedCard": return await scannedCardArchive(item, db); + case "createSelfAuthRequest": + case "getSelfAuthPatient": + case "submitSelfAuth": + case "getSelfAuthResult": + return await selfAuth(item, db, context); default: return { success: false, diff --git a/hlw/online-drug-info/index.js b/hlw/online-drug-info/index.js index 2cea081..d3cb597 100644 --- a/hlw/online-drug-info/index.js +++ b/hlw/online-drug-info/index.js @@ -196,6 +196,12 @@ async function addOnlineDrugInfo(ctx, db) { if (diagnosisIds.present) { newDrug.applicableDiagnosisIds = diagnosisIds.value; } + if (!Object.prototype.hasOwnProperty.call(newDrug, "antibiotic_level")) { + newDrug.antibiotic_level = 0; + } + if (!Object.prototype.hasOwnProperty.call(newDrug, "tumor_use")) { + newDrug.tumor_use = 0; + } newDrug.createTime = Date.now(); const res = await db .collection("online-drug-info") diff --git a/hlw/self-auth/index.js b/hlw/self-auth/index.js new file mode 100644 index 0000000..3f4d320 --- /dev/null +++ b/hlw/self-auth/index.js @@ -0,0 +1,332 @@ +const { ObjectId } = require("mongodb"); +const validator = require("../../utils/validator"); +const Sm4Util = require("../../utils/sm4-util"); +const hnHis = require("../hn-his"); + +const COLLECTION_NAME = "hlw-patient-self-auth"; +const PATIENT_COLLECTION_NAME = "hlw-patient"; +const AUTH_VALID_MS = 10 * 60 * 1000; +const RECORD_RETENTION_MS = 24 * 60 * 60 * 1000; +const indexPromises = new WeakMap(); + +async function removeLegacyAuthIdIndex(collection) { + try { + await collection.dropIndex("authId_1"); + } catch (error) { + // 旧索引不存在时无需处理;其他数据库错误仍应暴露,避免留下隐蔽的唯一索引冲突。 + if (error && (error.code === 27 || error.codeName === "IndexNotFound")) return; + throw error; + } +} + +function ensureIndexes(db) { + if (!indexPromises.has(db)) { + const collection = db.collection(COLLECTION_NAME); + const promise = removeLegacyAuthIdIndex(collection) + .then(() => Promise.all([ + collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }), + collection.createIndex({ ownerUserId: 1, status: 1 }), + ])) + .catch((error) => { + indexPromises.delete(db); + throw error; + }); + indexPromises.set(db, promise); + } + return indexPromises.get(db); +} + +function parseRequestId(value) { + const id = typeof value === "string" ? value.trim() : ""; + if (!/^[a-f\d]{24}$/i.test(id) || !ObjectId.isValid(id)) { + return { success: false, message: "授权链接无效" }; + } + return { + success: true, + id, + objectId: new ObjectId(id), + }; +} + +function isExpired(requestTimestamp, now = Date.now()) { + return now - requestTimestamp > AUTH_VALID_MS; +} + +function getCurrentUserId(context) { + const internalUserId = context && typeof context.userId === "string" + ? context.userId.trim() + : ""; + return ObjectId.isValid(internalUserId) ? internalUserId : ""; +} + +function decryptPatient(patient) { + return { + id: patient._id.toString(), + accountId: patient.accountId, + name: Sm4Util.decryptDataForSm3(patient.anotherName), + certNo: Sm4Util.decryptDataForSm3(patient.anotherIdNo), + mobile: Sm4Util.decryptDataForSm3(patient.anotherMobile), + address: typeof patient.address === "string" ? patient.address : "", + }; +} + +function maskCertNo(certNo) { + if (typeof certNo !== "string" || certNo.length <= 7) return "--"; + return `${certNo.slice(0, 4)}${"*".repeat(certNo.length - 7)}${certNo.slice(-3)}`; +} + +function getPersonInfo(certNo, now = new Date()) { + if (!validator.isChinaId(certNo)) return { age: "", sex: "" }; + const birthYear = Number(certNo.slice(6, 10)); + const birthMonth = Number(certNo.slice(10, 12)); + const birthDay = Number(certNo.slice(12, 14)); + const birthday = new Date(birthYear, birthMonth - 1, birthDay); + if ( + birthday.getFullYear() !== birthYear + || birthday.getMonth() + 1 !== birthMonth + || birthday.getDate() !== birthDay + || birthday > now + ) { + return { age: "", sex: "" }; + } + let age = now.getFullYear() - birthYear; + const birthdayPassed = now.getMonth() + 1 > birthMonth + || (now.getMonth() + 1 === birthMonth && now.getDate() >= birthDay); + if (!birthdayPassed) age -= 1; + return { + age: Math.max(0, age), + sex: Number(certNo.charAt(16)) % 2 === 0 ? "女" : "男", + }; +} + +async function findRequest(db, parsed) { + return db.collection(COLLECTION_NAME).findOne({ _id: parsed.objectId }); +} + +async function createSelfAuthRequest(item, db, context) { + const patientId = item && typeof item.patientId === "string" ? item.patientId.trim() : ""; + if (!ObjectId.isValid(patientId)) { + return { success: false, message: "就诊人ID格式不正确" }; + } + + try { + await ensureIndexes(db); + const ownerUserId = getCurrentUserId(context); + const accountId = item && typeof item.accountId === "string" ? item.accountId.trim() : ""; + if (!ownerUserId || !accountId) return { success: false, message: "登录信息无效,请重新登录" }; + + const patient = await db.collection(PATIENT_COLLECTION_NAME).findOne({ + _id: new ObjectId(patientId), + accountId, + disabled: false, + }); + if (!patient) return { success: false, message: "就诊人不存在或无权操作" }; + + const requestTimestamp = Date.now(); + const insertResult = await db.collection(COLLECTION_NAME).insertOne({ + patientId: new ObjectId(patientId), + ownerAccountId: accountId, + ownerUserId, + requestTimestamp, + status: "pending", + attempts: 0, + createdAt: new Date(requestTimestamp), + updatedAt: new Date(requestTimestamp), + expiresAt: new Date(requestTimestamp + RECORD_RETENTION_MS), + }); + return { + success: true, + message: "授权请求创建成功", + data: { id: insertResult.insertedId.toString() }, + }; + } catch (error) { + return { success: false, message: error.message || "授权请求创建失败" }; + } +} + +async function getSelfAuthPatient(item, db) { + const parsed = parseRequestId(item && item.id); + if (!parsed.success) return parsed; + + try { + await ensureIndexes(db); + const request = await findRequest(db, parsed); + if (!request) return { success: false, message: "授权链接不存在或已失效" }; + if (request.status !== "success" && isExpired(request.requestTimestamp)) { + return { success: false, message: "授权链接已过期,请重新发起授权" }; + } + + const patient = await db.collection(PATIENT_COLLECTION_NAME).findOne({ + _id: request.patientId, + accountId: request.ownerAccountId, + disabled: false, + }); + if (!patient) return { success: false, message: "就诊人不存在" }; + + const data = decryptPatient(patient); + const personInfo = getPersonInfo(data.certNo); + return { + success: true, + message: "获取就诊人成功", + data: { + name: data.name, + certNo: maskCertNo(data.certNo), + age: personInfo.age, + sex: personInfo.sex, + status: request.status, + }, + }; + } catch (error) { + return { success: false, message: error.message || "获取授权信息失败" }; + } +} + +async function submitSelfAuth(item, db, context) { + const parsed = parseRequestId(item && item.id); + if (!parsed.success) return parsed; + const psnToken = item && typeof item.psnToken === "string" ? item.psnToken.trim() : ""; + if (!psnToken || psnToken.length > 2048) { + return { success: false, message: "医保授权信息无效" }; + } + + const collection = db.collection(COLLECTION_NAME); + try { + await ensureIndexes(db); + const request = await findRequest(db, parsed); + if (!request) return { success: false, message: "授权链接不存在或已失效" }; + if (request.status === "success") { + return { success: true, message: "已完成授权", data: { status: "success" } }; + } + if (isExpired(request.requestTimestamp)) { + return { success: false, message: "授权链接已过期,请重新发起授权" }; + } + + const authorizerUserId = getCurrentUserId(context); + if (!authorizerUserId) return { success: false, message: "登录信息无效,请重新登录" }; + + const claimed = await collection.findOneAndUpdate( + { _id: parsed.objectId, status: { $in: ["pending", "failed"] } }, + { + $set: { + status: "processing", + authorizerUserId, + errorMessage: "", + updatedAt: new Date(), + }, + $inc: { attempts: 1 }, + }, + { returnDocument: "after" } + ); + if (!claimed) { + const latest = await collection.findOne({ _id: parsed.objectId }); + if (latest && latest.status === "success") { + return { success: true, message: "已完成授权", data: { status: "success" } }; + } + return { success: false, message: "授权正在处理中,请勿重复提交", data: { status: "processing" } }; + } + + const patientRecord = await db.collection(PATIENT_COLLECTION_NAME).findOne({ + _id: claimed.patientId, + accountId: claimed.ownerAccountId, + disabled: false, + }); + if (!patientRecord) throw new Error("就诊人不存在"); + const patient = decryptPatient(patientRecord); + + const hisResult = await hnHis({ + type: "addHisCustomer", + idCard: patient.certNo, + name: patient.name, + mobile: patient.mobile, + address: patient.address, + psnToken, + }); + const patients = Array.isArray(hisResult && hisResult.list) ? hisResult.list : []; + const hisArchive = patients.find((entry) => entry && entry.socialno === patient.certNo); + if (!hisResult || !hisResult.success || !hisArchive) { + throw new Error((hisResult && hisResult.message) || "医保建档失败,未找到匹配档案"); + } + if (hisArchive.isyb !== "1") throw new Error("医保建档失败"); + + const result = { + ...hisArchive, + socialno: hisArchive.socialno || patient.certNo, + tmbxx: Array.isArray(hisResult.tmbxx) ? hisResult.tmbxx : [], + }; + const completedAt = new Date(); + await collection.updateOne( + { _id: parsed.objectId, status: "processing" }, + { + $set: { + status: "success", + encryptedResult: Sm4Util.encryptDataForSm3(JSON.stringify(result)), + encryption: "SM4", + completedAt, + updatedAt: completedAt, + }, + $unset: { errorMessage: "" }, + } + ); + return { success: true, message: "授权并建档成功", data: { status: "success" } }; + } catch (error) { + await collection.updateOne( + { _id: parsed.objectId, status: "processing" }, + { $set: { status: "failed", errorMessage: error.message || "医保建档失败", updatedAt: new Date() } } + ).catch(() => { }); + return { success: false, message: error.message || "医保建档失败", data: { status: "failed" } }; + } +} + +async function getSelfAuthResult(item, db, context) { + const parsed = parseRequestId(item && item.id); + console.log('parsed: ', parsed) + if (!parsed.success) return parsed; + try { + await ensureIndexes(db); + const ownerUserId = getCurrentUserId(context); + if (!ownerUserId) return { success: false, message: "登录信息无效,请重新登录" }; + const request = await findRequest(db, parsed); + console.log('request: ', request) + if (!request || request.ownerUserId !== ownerUserId) { + return { success: false, message: "授权记录不存在或无权访问" }; + } + + if (request.status === "success") { + if (!request.encryptedResult || request.encryption !== "SM4") { + return { success: false, message: "授权结果无效" }; + } + const hisArchive = JSON.parse(Sm4Util.decryptDataForSm3(request.encryptedResult)); + return { success: true, message: "授权成功", data: { status: "success", hisArchive } }; + } + if (request.status !== "processing" && isExpired(request.requestTimestamp)) { + return { success: true, message: "授权链接已过期", data: { status: "expired" } }; + } + return { + success: true, + message: request.errorMessage || "等待就诊人授权", + data: { status: request.status, errorMessage: request.errorMessage || "" }, + }; + } catch (error) { + return { success: false, message: error.message || "查询授权结果失败" }; + } +} + +module.exports = async (item, db, context) => { + switch (item.type) { + case "createSelfAuthRequest": + return createSelfAuthRequest(item, db, context); + case "getSelfAuthPatient": + return getSelfAuthPatient(item, db); + case "submitSelfAuth": + return submitSelfAuth(item, db, context); + case "getSelfAuthResult": + return getSelfAuthResult(item, db, context); + default: + return { success: false, message: "未找到本人授权接口" }; + } +}; + +module.exports.parseRequestId = parseRequestId; +module.exports.maskCertNo = maskCertNo; +module.exports.getPersonInfo = getPersonInfo; +module.exports.constants = { AUTH_VALID_MS, RECORD_RETENTION_MS, COLLECTION_NAME }; diff --git a/hlw/self-auth/index.test.js b/hlw/self-auth/index.test.js new file mode 100644 index 0000000..8f40444 --- /dev/null +++ b/hlw/self-auth/index.test.js @@ -0,0 +1,282 @@ +jest.mock("../../utils/sm4-util", () => ({ + encryptDataForSm3: jest.fn((value) => Buffer.from(value).toString("base64")), + decryptDataForSm3: jest.fn((value) => { + const patientValues = { + encryptedName: "张三", + encryptedCertNo: "11010519491231002X", + encryptedMobile: "13800138000", + }; + return patientValues[value] || Buffer.from(value, "base64").toString(); + }), +})); +jest.mock("../hn-his", () => jest.fn()); + +const { ObjectId } = require("mongodb"); +const Sm4Util = require("../../utils/sm4-util"); +const hnHis = require("../hn-his"); +const selfAuth = require("./index"); + +const OWNER_USER_ID = "507f1f77bcf86cd799439011"; +const OTHER_USER_ID = "507f191e810c19729de860ea"; +const PATIENT_ID = "507f1f77bcf86cd799439012"; +const REQUEST_ID = "507f1f77bcf86cd799439013"; +const ACCOUNT_ID = "2088000000000000"; + +function sameValue(left, right) { + if (left instanceof ObjectId || right instanceof ObjectId) { + return String(left) === String(right); + } + return left === right; +} + +function matches(record, query) { + return Object.entries(query).every(([key, expected]) => { + if (expected && expected.$in) return expected.$in.includes(record[key]); + return sameValue(record[key], expected); + }); +} + +function createDb({ requests = [], patient = null } = {}) { + const state = { requests, patient }; + const authCollection = { + dropIndex: jest.fn().mockRejectedValue(Object.assign(new Error("index not found"), { + code: 27, + codeName: "IndexNotFound", + })), + createIndex: jest.fn().mockResolvedValue("index"), + insertOne: jest.fn(async (record) => { + if (!record._id) record._id = new ObjectId(); + state.requests.push(record); + return { insertedId: record._id }; + }), + findOne: jest.fn(async (query) => state.requests.find((record) => matches(record, query)) || null), + findOneAndUpdate: jest.fn(async (query, update) => { + const record = state.requests.find((entry) => matches(entry, query)); + if (!record) return null; + Object.assign(record, update.$set || {}); + if (update.$inc) { + Object.entries(update.$inc).forEach(([key, value]) => { + record[key] = (record[key] || 0) + value; + }); + } + return record; + }), + updateOne: jest.fn(async (query, update) => { + const record = state.requests.find((entry) => matches(entry, query)); + if (!record) return { modifiedCount: 0 }; + Object.assign(record, update.$set || {}); + if (update.$unset) Object.keys(update.$unset).forEach((key) => delete record[key]); + return { modifiedCount: 1 }; + }), + }; + const patientCollection = { + findOne: jest.fn(async (query) => { + if (!state.patient || !matches(state.patient, query)) return null; + return state.patient; + }), + }; + const db = { + collection: jest.fn((name) => { + if (name === "hlw-patient-self-auth") return authCollection; + if (name === "hlw-patient") return patientCollection; + throw new Error(`Unexpected collection: ${name}`); + }), + }; + return { db, state, authCollection }; +} + +function makePatient() { + return { + _id: new ObjectId(PATIENT_ID), + accountId: ACCOUNT_ID, + anotherName: "encryptedName", + anotherIdNo: "encryptedCertNo", + anotherMobile: "encryptedMobile", + address: "测试地址", + disabled: false, + }; +} + +function makeRequest(status = "pending", timestamp = Date.now()) { + return { + _id: new ObjectId(REQUEST_ID), + patientId: new ObjectId(PATIENT_ID), + ownerAccountId: ACCOUNT_ID, + ownerUserId: OWNER_USER_ID, + requestTimestamp: timestamp, + status, + attempts: 0, + }; +} + +function requestId(request) { + return request._id.toString(); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test("rejects malformed authorization ids", async () => { + const { db } = createDb(); + await expect(selfAuth({ type: "getSelfAuthPatient", id: "bad-id" }, db, {})) + .resolves.toEqual({ success: false, message: "授权链接无效" }); + expect(db.collection).not.toHaveBeenCalled(); +}); + +test("creates a pending request owned by the authenticated user", async () => { + const { db, state } = createDb({ patient: makePatient() }); + const result = await selfAuth({ + type: "createSelfAuthRequest", + patientId: PATIENT_ID, + accountId: ACCOUNT_ID, + }, db, { userId: OWNER_USER_ID }); + + expect(result.success).toBe(true); + expect(result.data.id).toMatch(/^[a-f\d]{24}$/i); + expect(result.data.id).toBe(state.requests[0]._id.toString()); + expect(state.requests[0]).toEqual(expect.objectContaining({ + ownerAccountId: ACCOUNT_ID, + ownerUserId: OWNER_USER_ID, + status: "pending", + })); + expect(state.requests[0]).not.toHaveProperty("authId"); + expect(state.requests[0]).not.toHaveProperty("psnToken"); +}); + +test("returns masked patient details and rejects an expired request", async () => { + const active = makeRequest(); + const activeDb = createDb({ requests: [active], patient: makePatient() }).db; + const activeResult = await selfAuth({ type: "getSelfAuthPatient", id: requestId(active) }, activeDb, {}); + expect(activeResult).toEqual(expect.objectContaining({ + success: true, + data: expect.objectContaining({ + name: "张三", + certNo: "1101***********02X", + sex: "女", + }), + })); + + const expired = makeRequest("pending", Date.now() - 11 * 60 * 1000); + const expiredDb = createDb({ requests: [expired], patient: makePatient() }).db; + const expiredResult = await selfAuth({ type: "getSelfAuthPatient", id: requestId(expired) }, expiredDb, {}); + expect(expiredResult).toEqual({ success: false, message: "授权链接已过期,请重新发起授权" }); +}); + +test("rejects missing requests and patients", async () => { + const missingRequest = makeRequest(); + const emptyDb = createDb({ patient: makePatient() }).db; + await expect(selfAuth({ type: "getSelfAuthPatient", id: requestId(missingRequest) }, emptyDb, {})) + .resolves.toEqual({ success: false, message: "授权链接不存在或已失效" }); + + const request = makeRequest(); + const noPatientDb = createDb({ requests: [request] }).db; + await expect(selfAuth({ type: "getSelfAuthPatient", id: requestId(request) }, noPatientDb, {})) + .resolves.toEqual({ success: false, message: "就诊人不存在" }); +}); + +test("reports zero years old for a valid infant id", () => { + const now = new Date(); + const date = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + ].join(""); + const body = `110105${date}001`; + const factors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + const parity = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"]; + const sum = body.split("").reduce((total, digit, index) => total + Number(digit) * factors[index], 0); + const certNo = `${body}${parity[sum % 11]}`; + expect(selfAuth.getPersonInfo(certNo, now).age).toBe(0); +}); + +test("stores only the encrypted matching HIS archive after successful submission", async () => { + const request = makeRequest(); + const { db, state } = createDb({ requests: [request], patient: makePatient() }); + hnHis.mockResolvedValue({ + success: true, + list: [{ socialno: "11010519491231002X", isyb: "1", patientId: "his-patient", blhno: "card-no" }], + tmbxx: [{ code: "11", name: "普通门诊" }], + }); + + const result = await selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "secret-token" }, db, { userId: OTHER_USER_ID }); + expect(result).toEqual({ success: true, message: "授权并建档成功", data: { status: "success" } }); + expect(state.requests[0].status).toBe("success"); + expect(state.requests[0].encryptedResult).toBeTruthy(); + expect(state.requests[0]).not.toHaveProperty("psnToken"); + expect(JSON.stringify(state.requests[0])).not.toContain("secret-token"); + expect(Sm4Util.encryptDataForSm3).toHaveBeenCalledWith(expect.stringContaining("his-patient")); +}); + +test("allows a failed request to be retried", async () => { + const request = makeRequest("failed"); + request.errorMessage = "previous failure"; + const { db, state } = createDb({ requests: [request], patient: makePatient() }); + hnHis.mockResolvedValue({ + success: true, + list: [{ socialno: "11010519491231002X", isyb: "1" }], + tmbxx: [], + }); + const result = await selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "new-token" }, db, { userId: OTHER_USER_ID }); + expect(result.success).toBe(true); + expect(state.requests[0].status).toBe("success"); + expect(state.requests[0].attempts).toBe(1); +}); + +test("only one concurrent submission can call HIS", async () => { + const request = makeRequest(); + const { db } = createDb({ requests: [request], patient: makePatient() }); + let releaseHis; + hnHis.mockImplementation(() => new Promise((resolve) => { + releaseHis = () => resolve({ + success: true, + list: [{ socialno: "11010519491231002X", isyb: "1" }], + tmbxx: [], + }); + })); + + const first = selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "token-1" }, db, { userId: OTHER_USER_ID }); + await new Promise((resolve) => setImmediate(resolve)); + const second = await selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "token-2" }, db, { userId: OTHER_USER_ID }); + expect(second).toEqual({ + success: false, + message: "授权正在处理中,请勿重复提交", + data: { status: "processing" }, + }); + expect(hnHis).toHaveBeenCalledTimes(1); + releaseHis(); + await expect(first).resolves.toEqual({ success: true, message: "授权并建档成功", data: { status: "success" } }); +}); + +test.each([ + [{ success: true, list: [{ socialno: "330100000000000000", isyb: "1" }] }, "医保建档失败,未找到匹配档案"], + [{ success: true, list: [{ socialno: "11010519491231002X", isyb: "0" }] }, "医保建档失败"], +])("marks invalid HIS results as failed", async (hisResult, message) => { + const request = makeRequest(); + const { db, state } = createDb({ requests: [request], patient: makePatient() }); + hnHis.mockResolvedValue(hisResult); + const result = await selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "token" }, db, { userId: OTHER_USER_ID }); + expect(result.success).toBe(false); + expect(result.message).toBe(message); + expect(state.requests[0].status).toBe("failed"); +}); + +test("is idempotent after success and restricts result access to the owner", async () => { + const archive = { socialno: "11010519491231002X", isyb: "1" }; + const request = { + ...makeRequest("success"), + encryptedResult: Buffer.from(JSON.stringify(archive)).toString("base64"), + encryption: "SM4", + }; + const { db } = createDb({ requests: [request], patient: makePatient() }); + + const repeated = await selfAuth({ type: "submitSelfAuth", id: requestId(request), psnToken: "token" }, db, { userId: OTHER_USER_ID }); + expect(repeated.success).toBe(true); + expect(hnHis).not.toHaveBeenCalled(); + + const denied = await selfAuth({ type: "getSelfAuthResult", id: requestId(request) }, db, { userId: OTHER_USER_ID }); + expect(denied).toEqual({ success: false, message: "授权记录不存在或无权访问" }); + + const allowed = await selfAuth({ type: "getSelfAuthResult", id: requestId(request) }, db, { userId: OWNER_USER_ID }); + expect(allowed).toEqual({ success: true, message: "授权成功", data: { status: "success", hisArchive: archive } }); +});