From 086d464145ebcc677ffcb30dd8a364cb49873fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 13 Jul 2026 12:52:36 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E7=89=B9=E8=89=B2=E6=96=B9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/js/content-security.js | 261 ++++++++++++++++++ common/js/oss-upload.js | 3 + common/js/select-avatar-image.js | 10 + common/js/wx-bind-helper.js | 94 +++++++ components/d-upload/d-upload.vue | 10 + components/wx-bind-entry/wx-bind-entry.vue | 37 +-- .../sub_business_shared/common/display.js | 19 +- .../sub_clinic_admin/common/display.js | 19 +- .../sub_clinic_salesperson/home/index.vue | 44 ++- .../sub_online_reception/pages/chat.vue | 18 +- .../sub_pharmacist/pharmacist_info.vue | 22 +- .../common/inputFormHelpers.js | 2 + .../components/PromoterDetailPopup.vue | 2 + .../modals/TransferPatientDrawer.vue | 15 +- .../sub_workbench/prescription_v2/index.vue | 5 +- .../sub_workbench/workbench_upInfo/index.vue | 24 ++ utils/utils.js | 4 +- 17 files changed, 530 insertions(+), 59 deletions(-) create mode 100644 common/js/content-security.js create mode 100644 common/js/wx-bind-helper.js diff --git a/common/js/content-security.js b/common/js/content-security.js new file mode 100644 index 0000000..3077888 --- /dev/null +++ b/common/js/content-security.js @@ -0,0 +1,261 @@ +/** + * 微信内容安全检查(医生端小程序) + * 调用 xk-api /api/doctor/content-security/* 接口 + */ +import { req } from '@/common/js/index.js'; +import { promptDoctorWxBind } from '@/common/js/wx-bind-helper.js'; + +/** 资料类场景(个人信息编辑) */ +export const SCENE_PROFILE = 1; +/** 社交日志场景(聊天) */ +export const SCENE_CHAT = 4; + +/** 微信 msg_sec_check 单条内容上限 */ +const MAX_TEXT_LEN = 2500; + +/** 需先绑定微信的错误文案片段 */ +const NEED_BIND_MSGS = ['请先绑定微信', '用户未绑定微信', '无法获取用户手机号', '绑定微信']; + +/** + * 从多种 reject 结构中提取错误文案(兼容 HTTP 4xx 拦截器固定 msg) + */ +function extractErrorMessage(err) { + if (!err) { + return ''; + } + const direct = err.msg || err.message; + if (direct && direct !== 'response 300-499') { + return String(direct); + } + const resData = err.res && err.res.data; + if (resData == null) { + return ''; + } + if (typeof resData === 'object') { + return String(resData.message || resData.msg || ''); + } + if (typeof resData === 'string') { + try { + const parsed = JSON.parse(resData); + return String((parsed && (parsed.message || parsed.msg)) || ''); + } catch (e) { + return ''; + } + } + return ''; +} + +/** + * 判断是否为未绑定微信导致的检测失败 + */ +export function isNeedBindError(err) { + const msg = extractErrorMessage(err); + return NEED_BIND_MSGS.some((item) => msg.includes(item)); +} + +/** + * 未绑定时弹窗确认,用户同意则绑定并重试检测 + */ +async function handleNeedBindAndRetry(retryFn) { + const confirmed = await promptDoctorWxBind(); + if (!confirmed) { + throw { msg: '已取消绑定,无法进行内容安全校验' }; + } + return retryFn(); +} + +/** + * 统一展示内容安全错误 + */ +export function handleSecurityError(err) { + const msg = extractErrorMessage(err) || '内容含有违规信息,请修改后重试'; + uni.showToast({ title: msg, icon: 'none' }); +} + +function assertCheckOk(res) { + if (res && (res.code === 0 || res.errcode === 0)) { + return true; + } + throw { msg: (res && (res.message || res.msg)) || '内容含有违规信息,请修改后重试' }; +} + +/** + * 将字段值规范为可比较的字符串(数组用逗号拼接) + */ +function normalizeFieldValue(val) { + if (val == null) { + return ''; + } + if (Array.isArray(val)) { + return val.filter(Boolean).map((item) => String(item).trim()).filter(Boolean).join(','); + } + return String(val).trim(); +} + +/** + * 与 baseline 对比,返回有变动且非空的文本列表 + * @param {Object} fields 当前表单字段 { key: value } + * @param {Object} baseline 初始快照 + */ +export function pickChangedTexts(fields, baseline = {}) { + const texts = []; + const base = baseline || {}; + Object.keys(fields || {}).forEach((key) => { + const current = normalizeFieldValue(fields[key]); + const original = normalizeFieldValue(base[key]); + if (current && current !== original) { + texts.push(current); + } + }); + return texts; +} + +/** + * 将多条文本用换行拼接;超过 2500 字则按字段拆成多批请求 + */ +export function joinTextsForCheck(texts) { + const list = (Array.isArray(texts) ? texts : []) + .map((item) => String(item || '').trim()) + .filter(Boolean); + if (!list.length) { + return []; + } + const batches = []; + let current = ''; + list.forEach((text) => { + const merged = current ? `${current}\n${text}` : text; + if (merged.length <= MAX_TEXT_LEN) { + current = merged; + return; + } + if (current) { + batches.push(current); + current = ''; + } + if (text.length <= MAX_TEXT_LEN) { + current = text; + } else { + // 单字段超长时按上限切分(就诊人场景几乎不会触发) + for (let i = 0; i < text.length; i += MAX_TEXT_LEN) { + batches.push(text.slice(i, i + MAX_TEXT_LEN)); + } + } + }); + if (current) { + batches.push(current); + } + return batches; +} + +/** + * 表单提交前:仅检测变动字段,合并为一次或少量 msg_sec_check 调用 + */ +export async function checkProfileTexts(fields, baseline, scene = SCENE_PROFILE) { + const changed = pickChangedTexts(fields, baseline); + if (!changed.length) { + return true; + } + const batches = joinTextsForCheck(changed); + for (let i = 0; i < batches.length; i++) { + await checkText(batches[i], scene); + } + return true; +} + +async function requestCheckText(content, scene) { + const text = String(content || '').trim(); + if (!text) { + return true; + } + const res = await req.request({ + url: '/newApi/content-security/check-text', + method: 'POST', + data: { content: text, scene }, + }); + return assertCheckOk(res); +} + +/** + * 文本内容安全检查(遇未绑定微信时弹窗确认后绑定并重试一次) + */ +export async function checkText(content, scene = SCENE_PROFILE) { + try { + return await requestCheckText(content, scene); + } catch (e) { + if (isNeedBindError(e)) { + return handleNeedBindAndRetry(() => requestCheckText(content, scene)); + } + throw e; + } +} + +/** + * 批量文本检查(按顺序,每条独立请求;聊天等场景不适用合并检测) + */ +export async function checkTexts(texts, scene = SCENE_PROFILE) { + const list = Array.isArray(texts) ? texts : [texts]; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (item) { + await checkText(item, scene); + } + } + return true; +} + +async function requestCheckImage(filePath, scene) { + return true; + const res = await req.request({ + url: '/newApi/content-security/check-image', + type: 'upload', + method: 'POST', + filePath, + name: 'file', + formData: { scene: String(scene) }, + }); + return assertCheckOk(res); +} + +/** + * 本地图片安全检查(遇未绑定微信时弹窗确认后绑定并重试一次) + */ +export async function checkImage(filePath, scene = SCENE_PROFILE) { + if (!filePath) { + return true; + } + try { + return await requestCheckImage(filePath, scene); + } catch (e) { + if (isNeedBindError(e)) { + return handleNeedBindAndRetry(() => requestCheckImage(filePath, scene)); + } + throw e; + } +} + +async function requestCheckImageUrl(url, scene) { + const res = await req.request({ + url: '/newApi/content-security/check-image-url', + method: 'POST', + data: { url, scene }, + }); + return assertCheckOk(res); +} + +/** + * 公网图片 URL 检查(遇未绑定微信时弹窗确认后绑定并重试一次) + */ +export async function checkImageUrl(url, scene = SCENE_PROFILE) { + const imageUrl = String(url || '').trim(); + if (!imageUrl) { + return true; + } + try { + return await requestCheckImageUrl(imageUrl, scene); + } catch (e) { + if (isNeedBindError(e)) { + return handleNeedBindAndRetry(() => requestCheckImageUrl(imageUrl, scene)); + } + throw e; + } +} diff --git a/common/js/oss-upload.js b/common/js/oss-upload.js index fd00ef1..16eb83f 100644 --- a/common/js/oss-upload.js +++ b/common/js/oss-upload.js @@ -5,6 +5,7 @@ * 例如 https://xiaokang88.oss-cn-hangzhou.aliyuncs.com */ import { getBusinessOssSignatureApi, unwrapOssSignature, registerBusinessOssFile } from '@/api/upload.js'; +import { checkImage } from '@/common/js/content-security.js'; /** * @param {string} filePath 本地临时文件路径 @@ -59,6 +60,8 @@ export async function uploadToOss(options) { throw new Error('文件路径为空'); } + await checkImage(filePath, 1); + const signature = await getOssSignatureFromBackend(); const objectName = generateObjectName(filePath); diff --git a/common/js/select-avatar-image.js b/common/js/select-avatar-image.js index ad53602..8724c1e 100644 --- a/common/js/select-avatar-image.js +++ b/common/js/select-avatar-image.js @@ -1,4 +1,5 @@ import { prepareImagePath } from '@/common/js/image-compress.js'; +import { checkImage, handleSecurityError, SCENE_PROFILE } from '@/common/js/content-security.js'; export function chooseAvatarImage(options = {}) { const { @@ -29,6 +30,15 @@ export function chooseAvatarImage(options = {}) { return; } + try { + await checkImage(prepared.path, SCENE_PROFILE); + } catch (error) { + uni.hideLoading(); + handleSecurityError(error); + onFail && onFail(error); + return; + } + uni.uploadFile({ url: uploadUrl, filePath: prepared.path, diff --git a/common/js/wx-bind-helper.js b/common/js/wx-bind-helper.js new file mode 100644 index 0000000..e4d8731 --- /dev/null +++ b/common/js/wx-bind-helper.js @@ -0,0 +1,94 @@ +/** + * 医生端微信绑定辅助方法 + * 供 wx-bind-entry 与内容安全自动绑定复用,避免重复 uni.login + bind 逻辑 + */ +import { bindDoctorWxWechat } from '@/api/doctorAuth.js'; + +/** + * 调用 uni.login 获取微信 code + * @returns {Promise} + */ +function getWxLoginCode() { + return new Promise((resolve, reject) => { + // #ifdef MP-WEIXIN + uni.login({ + provider: 'weixin', + success: (loginRes) => { + if (!loginRes.code) { + reject({ msg: '获取微信授权失败' }); + return; + } + resolve(loginRes.code); + }, + fail: () => reject({ msg: '微信授权失败' }), + }); + // #endif + // #ifndef MP-WEIXIN + reject({ msg: '请在微信小程序中使用' }); + // #endif + }); +} + +/** + * 确保当前医生账号已绑定微信 openid(内容安全等接口依赖) + * @param {Object} options + * @param {boolean} options.showLoading 是否展示 loading,组件内已有 loading 时可传 false + * @returns {Promise} 绑定成功返回 true + */ +export async function ensureDoctorWxBound(options = {}) { + const { showLoading = true } = options; + if (showLoading) { + uni.showLoading({ title: '绑定中...', mask: true }); + } + try { + const code = await getWxLoginCode(); + const { ok, res } = await bindDoctorWxWechat(code); + if (!ok) { + throw { + msg: (res && (res.message || res.msg)) || '绑定失败,请前往「我的 → 绑定微信」手动绑定', + }; + } + return true; + } finally { + if (showLoading) { + uni.hideLoading(); + } + } +} + +/** + * 弹出确认框,用户同意后再绑定微信(内容安全等被动触发场景使用) + * @param {Object} options + * @param {string} options.title 弹窗标题 + * @param {string} options.content 弹窗正文 + * @param {boolean} options.showLoading 绑定过程是否展示 loading + * @returns {Promise} 绑定成功返回 true,用户取消返回 false + */ +export function promptDoctorWxBind(options = {}) { + const { + title = '提示', + content = '内容安全校验需要绑定微信,是否立即绑定?', + showLoading = true, + } = options; + return new Promise((resolve, reject) => { + uni.showModal({ + title, + content, + confirmText: '去绑定', + cancelText: '取消', + success: async (res) => { + if (!res.confirm) { + resolve(false); + return; + } + try { + await ensureDoctorWxBound({ showLoading }); + resolve(true); + } catch (e) { + reject(e); + } + }, + fail: () => resolve(false), + }); + }); +} diff --git a/components/d-upload/d-upload.vue b/components/d-upload/d-upload.vue index cd9a0d7..47878f6 100644 --- a/components/d-upload/d-upload.vue +++ b/components/d-upload/d-upload.vue @@ -72,6 +72,7 @@ * @example */ import { prepareImagePath } from '@/common/js/image-compress.js'; + import { checkImage, handleSecurityError, SCENE_PROFILE } from '@/common/js/content-security.js'; export default { name: 'd-upload', @@ -415,6 +416,15 @@ } this.lists[index].error = false; this.uploading = true; + try { + await checkImage(this.lists[index].url, SCENE_PROFILE); + } catch (e) { + this.uploading = false; + uni.hideLoading(); + handleSecurityError(e); + this.lists[index].error = true; + return this.uploadFile(index + 1); + } // 创建上传对象 const task = uni.uploadFile({ url: this.action, diff --git a/components/wx-bind-entry/wx-bind-entry.vue b/components/wx-bind-entry/wx-bind-entry.vue index dc0fc16..5f22b20 100644 --- a/components/wx-bind-entry/wx-bind-entry.vue +++ b/components/wx-bind-entry/wx-bind-entry.vue @@ -12,8 +12,9 @@