fix(merchant): 进件草稿按操作员隔离,换账号不再看到上一个账号的资料

商户端换账号登录后,进件页会提示"已恢复上次未提交的草稿",并把**上一个账号**的姓名、
身份证号、银行卡号预填进来。这不是体验问题,是把一个账号的敏感资料泄露给了下一个账号。

根因:草稿存在固定的全局 key merchant-onboarding-draft 下,而 uni 的 storage 不区分账号,

estoreDraft() 不校验归属就直接恢复。

改动:
- 新增 services/onboarding-draft.uts:按当前登录的 operatorId 给草稿 key 加命名空间
(取不到 operationId 时退回门店维度,再取不到则退回旧 key,避免拼出会互相覆盖的空后缀);
key 在取用点现算,不在模块加载时固化。
- merchant-onboarding.uvue:草稿读写改走该 key;草稿里记录 ownerOperatorId,
restoreDraft 先校验归属,不符就丢弃并清掉——这样已经存在于旧全局 key 下的、属于别的账号的
草稿也会被主动丢掉,而不是继续被恢复。
- merchant-onboarding-pending.uvue:approved 时只清当前账号自己的草稿。
- session.uts:clearSession 顺带清掉当前操作员的草稿(先取会话再清,否则算不出 key)。
草稿含身份证号与银行卡号,没有理由跨会话长期留在设备上。

静态自检:无重复导入、导入符号均存在、模板标签配对(view 84/84、picker 14/14、text 98/98)。
1个文件已添加
3个文件已修改
61 ■■■■■ 已修改文件
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/onboarding-draft.uts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/session.uts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
@@ -17,6 +17,7 @@
<script setup lang="uts">
    import { loadOnboardingStatus, SettlementOnboarding } from '../../services/settlement.uts'
    import { getMerchantOnboardingDraftStorageKey } from '../../services/onboarding-draft.uts'
    const onboardingStatus = ref('pending')
    const statusReason = ref('')
@@ -46,7 +47,8 @@
                return
            }
            onboardingStatus.value = item.status
            if (item.status == 'approved') uni.removeStorageSync('merchant-onboarding-draft')
            // 只清当前账号自己的草稿:草稿 key 按操作员分命名空间,不能误删别的账号的。
            if (item.status == 'approved') uni.removeStorageSync(getMerchantOnboardingDraftStorageKey())
            statusReason.value = item.status == 'rejected' ? item.reason : ''
        }, (error: any) => {
            uni.showToast({ title: error.message != null ? error.message : '进件状态加载失败', icon: 'none' })
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -68,6 +68,8 @@
<script setup lang="uts">
    import { loadOnboardingStatus, SettlementOnboarding, submitOnboarding as submitOnboardingRemote } from '../../services/settlement.uts'
    import { uploadMerchantProductImage } from '../../services/merchant-management.uts'
    import { getMerchantOnboardingDraftStorageKey } from '../../services/onboarding-draft.uts'
    import { getSession } from '../../services/session.uts'
    import { areaProvinceNames, areaProvinceCodes, areaCityNames, areaCityCodes } from '../../config/area-codes.uts'
    const stepTitles = ['主体信息', '法人信息', '联系人', '结算账户']
@@ -85,7 +87,14 @@
    const businessValidityIndex = ref(0)
    const subjectValidityOptions = ['10年', '20年', '长期有效']
    const subjectValidityIndex = ref(0)
    const draftStorageKey = 'merchant-onboarding-draft'
    // 草稿按当前登录的操作员分命名空间,换账号看不到上一个账号的草稿(见 services/onboarding-draft.uts)。
    // 必须在取用点现算:进入页面时机不确定,模块加载时算会把当时的会话固化住。
    const draftStorageKey = (): string => getMerchantOnboardingDraftStorageKey()
    const currentOperatorId = (): string => {
        const session = getSession()
        if (session == null || session.operatorId == null) return ''
        return (session.operatorId as string).trim()
    }
    let draftRestored = false
    /** 私营企业等类型汇付要求必须填注册资本,这里与服务端 normalizeRegisteredCapital 保持一致。 */
@@ -162,14 +171,19 @@
    }
    const goPrevStep = () => { saveDraft(); if (currentStep.value > 1) currentStep.value = currentStep.value - 1; resetScroll() }
    const draftPayload = (): any => ({ merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, currentStep: currentStep.value })
    const saveDraft = () => { uni.setStorageSync(draftStorageKey, JSON.stringify(draftPayload())) }
    const clearDraft = () => { uni.removeStorageSync(draftStorageKey) }
    const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, currentStep: currentStep.value })
    const saveDraft = () => { uni.setStorageSync(draftStorageKey(), JSON.stringify(draftPayload())) }
    const clearDraft = () => { uni.removeStorageSync(draftStorageKey()) }
    const restoreDraft = () => {
        const stored = uni.getStorageSync(draftStorageKey)
        const stored = uni.getStorageSync(draftStorageKey())
        if (stored == null || stored == '') return
        try {
            const draft = JSON.parse(stored as string) as any
            // 草稿必须属于当前登录的操作员。key 已经按操作员分命名空间,这里再校验一次归属:
            // 更新前存在旧的全局 key 下的草稿,里面可能有别的账号的姓名、身份证号、银行卡号;
            // 归属不符就直接丢弃,宁可让用户重填,也不能把别人的资料预填进来。
            const owner = draft.ownerOperatorId != null ? (draft.ownerOperatorId as string) : ''
            if (owner != currentOperatorId()) { clearDraft(); return }
            if (draft.merchantType != null) merchantType.value = draft.merchantType as string
            if (draft.merchantName != null) merchantName.value = draft.merchantName as string
            if (draft.merchantShortName != null) merchantShortName.value = draft.merchantShortName as string
@@ -216,6 +230,7 @@
            const restoredBusinessValidity = validityOptions.indexOf(businessValidity.value); if (restoredBusinessValidity >= 0) businessValidityIndex.value = restoredBusinessValidity
            const restoredSubjectValidity = subjectValidityOptions.indexOf(subjectValidity.value); if (restoredSubjectValidity >= 0) subjectValidityIndex.value = restoredSubjectValidity
            if (draft.currentStep != null) { const restoredStep = Number(draft.currentStep); currentStep.value = (restoredStep >= 1 && restoredStep <= 4) ? restoredStep : 1 }
            // 走到这里才说明草稿确实属于当前账号,这时才提示"已恢复草稿"。
            draftNotice.value = true
        } catch (_error) { clearDraft() }
    }
rencaika_merchant/services/onboarding-draft.uts
New file
@@ -0,0 +1,25 @@
import { getSession } from './session.uts'
/**
 * 进件草稿的 storage key。
 *
 * 草稿原本存在一个固定的全局 key(`merchant-onboarding-draft`)里,而 `uni` 的 storage
 * 不区分账号:所以在商户端 A 账号填了一半退出、换成 B 账号登录后,B 打开进件页会看到
 * 「已恢复上次未提交的草稿」,并把 **A 的姓名、身份证号、银行卡号**预填进 B 的表单。
 * 这不只是体验问题,是把上一个账号的敏感信息泄露给了下一个账号。
 *
 * 所以按「当前登录的操作员」给 key 加命名空间:换账号即换 key,彼此看不到对方的草稿。
 *
 * 退路:会话还没建立时(例如开发态的登录旁路)取不到 operatorId,此时回退到门店维度;
 * 再取不到就退回不带后缀的旧 key——宁可退回旧行为,也不能让 key 变成 `...-` 这种会互相覆盖的形态。
 */
export const getMerchantOnboardingDraftStorageKey = (): string => {
    const prefix = 'merchant-onboarding-draft'
    const session = getSession()
    if (session == null) return prefix
    const operator = session.operatorId != null ? (session.operatorId as string).trim() : ''
    if (operator.length > 0) return prefix + '-' + operator
    const store = session.storeId != null ? (session.storeId as string).trim() : ''
    if (store.length > 0) return prefix + '-store-' + store
    return prefix
}
rencaika_merchant/services/session.uts
@@ -25,6 +25,13 @@
}
export const clearSession = () => {
    // 退出时把当前操作员的进件草稿一起清掉。草稿里有姓名、身份证号、银行卡号,
    // 让它在设备上跨会话留存没有收益,反而会在换账号时被误当成"上次未提交的草稿"恢复出来。
    // 顺序很重要:必须先取到操作员再清会话,否则算不出这个账号的草稿 key。
    // 只删当前操作员的那一份,不会误删同一台设备上别的账号的草稿。
    const session = getSession()
    const operator = session != null && session.operatorId != null ? (session.operatorId as string).trim() : ''
    if (operator.length > 0) uni.removeStorageSync('merchant-onboarding-draft-' + operator)
    uni.removeStorageSync(sessionStorageKey)
}