shinnosuke
2026-09-18 131a28347f2c3ce272f61fce425a6eea4fa5e51c
更新....
2个文件已添加
13个文件已修改
319 ■■■■■ 已修改文件
.scratch/merchant-individual-onboarding/issues/01-settlement-card-front-not-persisted.md 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/development-server.uts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news/news.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/order-processing/order-processing.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/business.uts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/development-server.uts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/product-detail/product-detail.uvue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/products/products.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/agreement/AgreementPortalService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/agreement/MerchantAgreementService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/agreement/AgreementPortalServiceTests.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-individual-onboarding/issues/01-settlement-card-front-not-persisted.md
New file
@@ -0,0 +1,66 @@
# 商户进件详情:结算银行卡卡号面图片显示为空
Status: ready-for-agent
领域: 支付链路(汇付进件/开通)
来源: 用户工单「【结算记录】商户进件:上传的银行卡图片没有显示出来」(截图记录 id=454,商户 644 景区服务-卡)
日期: 2026-09-18
## 现象
管理后台「结算记录 → 商户进件」详情页里,**结算银行卡卡号面**一直显示 `-`,没有「查看图片」链接;
同一张表单上的其他图片(个人身份证人像面、国徽面、店铺照片、店内环境照)都能正常打开。
## 根因
`merchant_onboarding_application.settlement_card_front_url` 这一列**从来没有被写入**,所以后台详情读不到:
- 商家端小程序提交时是**带了**这个值的:`rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue:280`
  (个人 / 个体工商户取 `settlementProofUrl`,企业非法人主体则放到 `registrationAccountUrl`);
- 后端也在用它:`MerchantSettlementService:170` 对个人主体做必填校验、`:236 / :261` 把它作为汇付图片
  `F13` 上传;
- 但写库的那条 INSERT(`MerchantSettlementService:184`)的列清单里**没有** `settlement_card_front_url`,
  35 个参数里也没有它 —— 值被静默丢弃,列恒为 NULL。
后台详情走的是 `select a.* ...`(`MerchantSettlementAdminService:135`),前端读 `detail.settlementCardFrontUrl`
(`MerchantOnboardingDetailView.vue:168`),所以列是空的就显示 `-`。**不是前端没展示,是后端没存。**
## 证据
1. 库(2026-09-18 只读查询测试/开发库 192.168.110.13 `talent_card`):
```sql
select id, merchant_id, subject_name, settlement_card_front_url,
       legal_id_front_url, legal_id_back_url, store_header_url, store_indoor_url
from merchant_onboarding_application where subject_name = '林熙';
```
```
453 | 636 | 林熙 | NULL | http://…/7ea41ede….jpg | http://…/dcb0e14e….jpg | http://…/a50a0aa0….jpg | http://…/0a65d845….jpg
454 | 644 | 林熙 | NULL | …(同上,均有值)
455 | 649 | 林熙 | NULL | …
456 | 649 | 林熙 | NULL | …
```
→ 4 条记录 `settlement_card_front_url` **全为 NULL**,同一行的其它图片 URL 都有值。
2. 代码:
   - 列存在:`MerchantOnboardingSchemaInitializer:96`(`settlement_card_front_url varchar(512) default null`);
   - 请求字段存在:`MerchantOnboardingRequest:43`;
   - **INSERT 缺列**:`MerchantSettlementService:184` 的 insert 语句;
   - 全仓搜索 `settlement_card_front_url` / `settlementCardFrontUrl`,除 DDL 初始化、请求对象、上面那两处上传
     调用外,没有任何 INSERT / UPDATE 写这列。
## 建议改法(支付链路负责人)
1. 在 `MerchantSettlementService:184` 的 INSERT 里补上 `settlement_card_front_url` 列,参数位置对应
   `clean(request.settlementCardFrontUrl())`(跟 `legal_id_front_url` 等 URL 字段同样的处理方式即可)。
2. 该字段在个人/个体工商户进件时是必填的(`:170` 已校验),补列之后新提交的进件记录后台就能看到图片。
3. 历史数据:已有 4 条为 NULL,无法从库里还原,只能让商户重新提交一次(或按汇付侧的图片记录回填)——
   建议改完代码后单独确认是否需要补数据。
4. 建议补一个测试:提交个人进件后断言 `merchant_onboarding_application.settlement_card_front_url` 非空,
   且后台详情接口返回该字段(避免以后再出现「上传了但没存」)。
## Comments
- 2026-09-18:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路的代码未改动)。待转交支付链路负责人。
rencaika/config/development-server.uts
@@ -24,14 +24,28 @@
 */
const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080'
/**
 * 真机调试用的开发机局域网地址:手机上的 127.0.0.1 指手机自己,连不到后台,
 * 必须用开发机的局域网 IP。换网/换 IP 时改这一行即可(也可以在真机调试控制台用下面的 storage 覆盖)。
 */
const lanDevelopmentBaseUrl = 'http://192.168.110.13:8080'
/** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */
const overrideStorageKey = 'development-api-base-url'
/** 开发环境基础地址:先看真机调试的覆盖值,否则用回环地址。 */
/**
 * 开发环境基础地址:优先级
 * 1. 真机调试时手动写入的 storage 覆盖值;
 * 2. 微信开发者工具 → 回环地址 127.0.0.1(工具与后台同机,永远可达);
 * 3. 真机 → 开发机局域网地址(手机访问不了 127.0.0.1)。
 */
export const developmentApiBaseUrl = (): string => {
    const override = uni.getStorageSync(overrideStorageKey)
    if (override != null && (override as string).trim().length > 0) return (override as string).trim()
    return loopbackDevelopmentBaseUrl
    const info = uni.getSystemInfoSync()
    const platform = info != null && info.platform != null ? '' + info.platform : ''
    if (platform == 'devtools') return loopbackDevelopmentBaseUrl
    return lanDevelopmentBaseUrl
}
/**
rencaika/pages/merchant/merchant.uvue
@@ -123,7 +123,7 @@
<script setup lang="uts">
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { getFuelMerchantDetail, getMedicalPackages, getMerchantDetail, getParkingMerchantDetail, getProducts, getTalentCardProfile, submitFuelMobileRegistration } from '../../services/portal.uts'
    import { getBenefitQuota, getFuelMerchantDetail, getMedicalPackages, getMerchantDetail, getParkingMerchantDetail, getProducts, getTalentCardProfile, submitFuelMobileRegistration } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
@@ -618,6 +618,8 @@
    }
    onShow(() => {
        if (isParkingMerchant.value && parkingDetailLoaded) loadParkingMerchant()
        // 酒店:从免费券页/核销后返回时刷新剩余次数,按钮在「出示免费券 / 出示人才码」之间切换。
        refreshHotelVoucherAvailability()
    })
    const loadRemoteMerchant = () => {
        if (isFuelMerchant.value || isParkingMerchant.value) { loadTransportMerchantDetail(); return }
@@ -679,6 +681,7 @@
                const freeVoucherTimes = data != null && data.freeVoucherTimes != null ? String(data.freeVoucherTimes).trim() : ''
                merchantDiscount.value = freeVoucherTimes != '' ? '免费' + freeVoucherTimes + '次' : '免费'
                merchantPrimaryTag.value = '券'
                refreshHotelVoucherAvailability()
            }
            if (isHotelMerchant.value && talentId.value != '' && talentCardId.value == '') {
                getTalentCardProfile(talentId.value, (profile: any) => {
@@ -853,6 +856,17 @@
        if (item.available === false) { uni.showToast({ title: item.unavailableReason || '该套餐暂不可预约', icon: 'none' }); return }
        uni.navigateTo({ url: '/pages/medical-package/medical-package?packageId=' + encodeURIComponent(item.id) + '&hospitalId=' + encodeURIComponent(merchantId.value) + '&name=' + encodeURIComponent(item.name) + '&hospital=' + encodeURIComponent(merchantName.value) + '&applicableCards=' + encodeURIComponent(applicableCards.value) })
    }
    // 酒店底部按钮显示「出示免费券」还是「出示人才码」,除了看后台是否启用免费券,
    // 还要看**剩余次数**:次数用完就得出示人才码,否则点进去只会看到"暂无可用免费券"。
    const refreshHotelVoucherAvailability = () => {
        if (!isHotelMerchant.value || !hasFreeVoucher.value || merchantId.value == '' || talentId.value == '') return
        getBenefitQuota('HOTEL', talentId.value, talentCardId.value, merchantId.value, (quota: any) => {
            const remaining = quota != null && quota.quotaRemaining != null ? Number(quota.quotaRemaining) : 0
            hasFreeVoucher.value = quota != null && quota.available === true && remaining > 0
        }, () => {
            // 额度查询失败时保持原判断:不因为一次额度查询失败就把按钮换成人才码。
        })
    }
    const showBottomVoucher = () => {
        if (isHotelMerchant.value && hasFreeVoucher.value) {
            if (talentId.value != '' && talentCardId.value == '') {
rencaika/pages/news/news.uvue
@@ -21,7 +21,8 @@
                        <text class="news-summary">{{item.summary}}</text>
                        <view class="news-meta"><text v-if="item.tag != ''" class="news-tag">{{item.tag}}</text><text class="news-time">{{item.time}}</text></view>
                    </view>
                    <image class="news-thumb" src="/static/news-assets/meeting-thumb.jpg" mode="aspectFill"></image>
                    <!-- 缩略图用后台配置的封面;没配就留空(灰底占位),不再统一显示那张默认会议厅图。 -->
                    <image class="news-thumb" :src="item.image != null ? item.image : ''" mode="aspectFill" @error="replaceImage(item)"></image>
                </view>
            </view>
            <view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view>
@@ -35,6 +36,7 @@
<script setup lang="uts">
    import { isApiConfigured } from '../../config/api.uts'
    import { getNews } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -55,6 +57,8 @@
    const failInitialOnce = ref(false)
    const failLoadMoreOnce = ref(false)
    const forceEmpty = ref(false)
    // 封面图加载失败时置空,交给灰底占位,避免出现破图。
    const replaceImage = (item: any) => { item.image = '' }
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -111,7 +115,9 @@
                title: item.title != null ? item.title as string : '人才资讯',
                summary: contentPreview(item.contentPreview),
                tag: newsTagText(item.summary),
                time: formatNewsTime(item.publishTime)
                time: formatNewsTime(item.publishTime),
                // 后台配置的封面图;相对路径补成接口域名,没配就留空走灰底占位。
                image: item.coverUrl != null && ('' + item.coverUrl).trim() != '' ? resolveApiUrl('' + item.coverUrl) : ''
            })
        }
        return mapped
@@ -225,6 +231,7 @@
       不覆盖的话标签不会收缩,只会顶出卡片、把时间挤没(省略号也不会出现)。 */
    .news-tag { display: block; flex: 0 1 auto; flex-grow: 0; flex-shrink: 1; flex-basis: auto; min-width: 0; padding: 5rpx 9rpx; border-radius: 6rpx; background-color: #FFF8EA; color: #F1A32D; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .news-time { margin-left: 13rpx; color: #A5A8AE; font-size: 20rpx; flex-shrink: 0; }
    .news-thumb { width: 236rpx; height: 168rpx; border-radius: 7rpx; flex-shrink: 0; }
    /* 灰底占位:后台没配封面时不会出现破图或那张默认会议厅图。 */
    .news-thumb { width: 236rpx; height: 168rpx; border-radius: 7rpx; background-color: #EEEEEE; flex-shrink: 0; }
    .bottom-space { height: calc(28rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/pages/order-processing/order-processing.uvue
@@ -29,7 +29,8 @@
                    <view v-if="!isSpouseEmployment" class="appointment-row"><text>预约时间:</text><text>{{appointmentTime}}</text></view>
                    <view v-if="!isSpouseEmployment" class="appointment-row"><text>预约单号:</text><text>{{applicationNo}}</text></view>
                    <view class="appointment-row"><text>提交时间:</text><text>{{submittedAt}}</text></view>
                    <view v-if="!isSpouseEmployment && advisorName != ''" class="appointment-row"><text>服务专员:</text><text>{{advisorName}} {{advisorDepartment}}</text></view>
                    <!-- 服务专员只显示专员名称,不再拼接职务(原来会显示成「6 成都」这种名称+职务)。 -->
                    <view v-if="!isSpouseEmployment && advisorName != ''" class="appointment-row"><text>服务专员:</text><text>{{advisorName}}</text></view>
                </view>
                <view class="content-bottom"></view>
            </view>
@@ -75,7 +76,6 @@
    const appointmentTime = ref('以专员通知为准')
    const submittedAt = ref('')
    const advisorName = ref('')
    const advisorDepartment = ref('')
    const advisorMobile = ref('')
    onLoad((options: any) => {
@@ -112,7 +112,6 @@
            submittedAt.value = ('' + data.submittedAt).replace('T', ' ').substring(0, 19)
            const advisor: any = data.advisor || {}
            advisorName.value = advisor.name || ''
            advisorDepartment.value = advisor.department || ''
            advisorMobile.value = advisor.mobile || ''
            loading.value = false
        }, () => { loading.value = false; loadError.value = true })
rencaika/services/business.uts
@@ -18,6 +18,11 @@
    // 而原来这里会把所有非 https 的 src 整条删掉,图片就变成破图(只显示 alt 文字)。
    // 这里统一补成接口域名下的绝对地址,http(s) 绝对地址原样保留。
    return content
        // 小程序 rich-text 不认 <s>/<strike>/<u>,换成它支持的 <del>/<ins>,
        // 否则后台设的删除线/下划线在人才端看不见(看着像"样式没生效")。
        .replace(/<\s*(\/?)\s*s(?=[\s>/])([^>]*)>/gi, '<$1del$2>')
        .replace(/<\s*(\/?)\s*strike(?=[\s>/])([^>]*)>/gi, '<$1del$2>')
        .replace(/<\s*(\/?)\s*u(?=[\s>/])([^>]*)>/gi, '<$1ins$2>')
        .replace(/(src|href)=(["'])\/\//gi, '$1=$2https://')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))\/+/gi, '$1' + base + '/')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))(?!https?:|\/\/|\/|data:|javascript:)/gi, '$1' + base + '/')
rencaika_merchant/config/development-server.uts
@@ -24,14 +24,28 @@
 */
const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080'
/**
 * 真机调试用的开发机局域网地址:手机上的 127.0.0.1 指手机自己,连不到后台,
 * 必须用开发机的局域网 IP。换网/换 IP 时改这一行即可(也可以在真机调试控制台用下面的 storage 覆盖)。
 */
const lanDevelopmentBaseUrl = 'http://192.168.110.13:8080'
/** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */
const overrideStorageKey = 'development-api-base-url'
/** 开发环境基础地址:先看真机调试的覆盖值,否则用回环地址。 */
/**
 * 开发环境基础地址:优先级
 * 1. 真机调试时手动写入的 storage 覆盖值;
 * 2. 微信开发者工具 → 回环地址 127.0.0.1(工具与后台同机,永远可达);
 * 3. 真机 → 开发机局域网地址(手机访问不了 127.0.0.1)。
 */
export const developmentApiBaseUrl = (): string => {
    const override = uni.getStorageSync(overrideStorageKey)
    if (override != null && (override as string).trim().length > 0) return (override as string).trim()
    return loopbackDevelopmentBaseUrl
    const info = uni.getSystemInfoSync()
    const platform = info != null && info.platform != null ? '' + info.platform : ''
    if (platform == 'devtools') return loopbackDevelopmentBaseUrl
    return lanDevelopmentBaseUrl
}
/**
rencaika_merchant/pages/product-detail/product-detail.uvue
@@ -55,7 +55,8 @@
        </scroll-view>
        <view v-if="loadState == 'success'" class="bottom-actions">
            <view class="offline-button" :class="submitting ? 'action-disabled' : ''" @tap="offlineProduct"><text>商品下架</text></view>
            <!-- 按钮按商品当前状态取反:已下架显示「商品上架」,已上架显示「商品下架」。 -->
            <view class="offline-button" :class="[productStatus == 'offline' ? 'is-online' : '', submitting ? 'action-disabled' : '']" @tap="toggleProductStatus"><text>{{productStatus == 'offline' ? '商品上架' : '商品下架'}}</text></view>
            <view class="save-button" :class="submitting ? 'action-disabled' : ''" @tap="saveProduct"><text>{{submitting ? '保存中' : '保存'}}</text></view>
        </view>
    </view>
@@ -83,6 +84,8 @@
    const amountError = ref('')
    const coverError = ref('')
    const initialSnapshot = ref('')
    // 商品当前上下架状态:'online' / 'offline',决定底部按钮显示「商品下架」还是「商品上架」。
    const productStatus = ref('')
    const loadState = ref('loading')
    const traceId = ref('')
    const submitting = ref(false)
@@ -96,6 +99,7 @@
    }
    const applyDetail = (detail: MerchantProductDetail) => {
        productStatus.value = detail.status
        productName.value = detail.productName
        productAmount.value = detail.salePrice.toFixed(2)
        detailText.value = detail.detailText
@@ -254,13 +258,17 @@
        })
    }
    const offlineProduct = () => {
    // 底部按钮按当前状态取反:已下架就上架,已上架就下架(后端 PATCH 支持 online/offline,
    // 上架时后端会校验商品必须已审核通过)。
    const toggleProductStatus = () => {
        if (submitting.value) return
        const goingOnline = productStatus.value == 'offline'
        const nextStatus = goingOnline ? 'online' : 'offline'
        uni.showModal({
            title: '商品下架',
            content: '确认下架当前商品吗?',
            confirmText: '确认下架',
            confirmColor: '#F25353',
            title: goingOnline ? '商品上架' : '商品下架',
            content: goingOnline ? '确认上架当前商品吗?' : '确认下架当前商品吗?',
            confirmText: goingOnline ? '确认上架' : '确认下架',
            confirmColor: goingOnline ? '#F07A2E' : '#F25353',
            success: (result: any) => {
                if (!result.confirm) return
                submitting.value = true
@@ -268,16 +276,20 @@
                    submitting.value = false
                    initialSnapshot.value = buildSnapshot()
                    uni.removeStorageSync(getDraftStorageKey())
                    uni.showToast({ title: '商品已下架', icon: 'success' })
                    uni.showToast({ title: goingOnline ? '商品已上架' : '商品已下架', icon: 'success' })
                }
                if (useDemoData) {
                    updateDemoMerchantProductStatus(productId.value, 'offline')
                    const updated = updateDemoMerchantProductStatus(productId.value, nextStatus)
                    if (updated != null) productStatus.value = updated.status
                    finish()
                    return
                }
                updateMerchantProductStatus(productId.value, 'offline', (_product: MerchantProductDetail, _traceId: string) => finish(), (error: any) => {
                updateMerchantProductStatus(productId.value, nextStatus, (product: MerchantProductDetail, _traceId: string) => {
                    productStatus.value = product.status
                    finish()
                }, (error: any) => {
                    submitting.value = false
                    uni.showToast({ title: error.message != null ? error.message : '商品下架失败', icon: 'none' })
                    uni.showToast({ title: error.message != null ? error.message : (goingOnline ? '商品上架失败' : '商品下架失败'), icon: 'none' })
                })
            }
        })
@@ -406,6 +418,8 @@
    .bottom-actions { display: flex; height: 142rpx; padding: 20rpx 34rpx 28rpx; flex-direction: row; background-color: #FFFFFF; box-shadow: 0 -8rpx 24rpx rgba(41, 51, 67, 0.04); box-sizing: border-box; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; }
    .offline-button, .save-button { display: flex; height: 84rpx; justify-content: center; align-items: center; border-radius: 44rpx; font-size: 29rpx; line-height: 40rpx; font-weight: 700; box-sizing: border-box; }
    .offline-button { width: 218rpx; margin-right: 22rpx; border-width: 1rpx; border-style: solid; border-color: #FFB7B4; color: #F25353; background-color: #FFF6F5; }
    /* 已下架时按钮是「商品上架」,用中性偏正向的橙色,和下架的红色区分开。 */
    .offline-button.is-online { border-color: #FFC79A; color: #F07A2E; background-color: #FFF7EF; }
    .save-button { flex: 1; color: #FFFFFF; background-image: linear-gradient(90deg, #FF8B1F 0%, #FF4A37 100%); box-shadow: 0 12rpx 22rpx rgba(255, 94, 47, 0.18); }
    .input-placeholder { color: #B9BEC8; }
    .input-invalid { border-color: #F0746D; background-color: #FFF9F8; }
rencaika_merchant/pages/products/products.uvue
@@ -105,7 +105,9 @@
    const auditText = (status: string): string => status == 'approved' ? '已通过' : status == 'rejected' ? '不通过' : '审核中'
    const openProduct = (product: MerchantProduct) => uni.navigateTo({ url: '/pages/product-detail/product-detail?id=' + encodeURIComponent(product.id) })
    onLoad(() => {
    // 用 onShow:首次进入会加载,从商品详情改了上下架状态返回时也会重新拉一次,
    // 否则下架/上架后回到列表仍停在原来的标签订阅里,看起来像没生效。
    onShow(() => {
        reloadProducts()
    })
</script>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -220,7 +220,8 @@
}
function hasMissingRightsTag() {
  return requiresRightsTag() && rightsConfigs.some((item) => !String(item.tag || '').trim())
  // 只校验**开启中**的人才卡等级:关掉的等级不该卡住保存(标签仍可留空)。
  return requiresRightsTag() && rightsConfigs.some((item) => item.enabled && !String(item.tag || '').trim())
}
const tabs = computed(() => {
@@ -2700,7 +2701,7 @@
            <strong>{{ item.level }}</strong>
            <el-switch v-model="item.enabled" />
            <template v-if="requiresRightsTag()">
              <span><i class="merchant-rights-required">*</i>标签</span>
              <span><i v-if="item.enabled" class="merchant-rights-required">*</i><template v-else>&nbsp;</template>标签</span>
              <el-input v-model="item.tag" placeholder="请输入" />
            </template>
          </div>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -180,8 +180,8 @@
        if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); }
        if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); }
        if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); countSql.append(" and date(publish_time) <= ?"); args.add(publishEnd); }
        // 置顶优先;置顶组内按“修改时间”倒序(最新修改的排前面,和人才端人才资讯一致),非置顶按排序值倒序。
        sql.append(" order by is_top desc, case when is_top = 1 then updated_at end desc, sort_no desc, publish_time desc, id desc");
        // 置顶优先;置顶组内按“置顶时间”倒序(越后置顶越靠前,和人才端政策列表一致),非置顶按排序值倒序。
        sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
@@ -921,12 +921,18 @@
                left join merchant_product p on p.id = r.product_id
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0
                  and (r.product_id is null or r.id = (
                         select max(newer.id) from merchant_product_audit_record newer
                         where newer.deleted = 0 and newer.product_id = r.product_id))
                """.formatted(salesCountSelect("r.product_id", "p")));
        StringBuilder countSql = new StringBuilder("""
                select count(*)
                from merchant_product_audit_record r
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0
                  and (r.product_id is null or r.id = (
                         select max(newer.id) from merchant_product_audit_record newer
                         where newer.deleted = 0 and newer.product_id = r.product_id))
                """);
        List<Object> args = new ArrayList<>();
        if (merchantId != null) {
talent-card-server/src/main/java/com/talentcard/server/business/agreement/AgreementPortalService.java
@@ -2,6 +2,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.talentcard.server.business.DatabaseRows;
@@ -65,18 +66,32 @@
        return result;
    }
    private String sanitizeHtml(String html) {
        Safelist safelist = Safelist.relaxed()
            .removeTags("script")
            .addTags("span", "video", "source")
    String sanitizeHtml(String html) {
        // 图片 src 不走 Jsoup 的协议白名单:/uploads/xxx.png 这种相对地址会因为"不是 http/https"
        // 被整条删掉,人才端就拿不到图片(后台协议里传的图显示不出来)。这里手动挡掉危险协议。
        Safelist safelist = new Safelist()
            .addTags("a", "abbr", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup",
                    "dd", "del", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i",
                    "img", "ins", "li", "ol", "p", "pre", "q", "small", "span", "strong", "sub", "sup",
                    "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", "video", "source")
            .addAttributes(":all", "style", "class")
            .addAttributes("a", "href", "title")
            .addAttributes("img", "src", "alt", "title", "width", "height")
            .addAttributes("video", "src", "controls", "poster", "width", "height")
            .addAttributes("source", "src", "type")
            .addProtocols("a", "href", "http", "https")
            .addProtocols("img", "src", "http", "https")
            .addProtocols("video", "src", "http", "https")
            .addProtocols("source", "src", "http", "https");
        Document document = Jsoup.parseBodyFragment(Jsoup.clean(html == null ? "" : html, safelist));
            .addProtocols("a", "href", "http", "https");
        Document source = Jsoup.parseBodyFragment(html == null ? "" : html);
        source.outputSettings().prettyPrint(false);
        // 小程序 rich-text 不认 <s>/<strike>/<u>,换成它支持的 <del>/<ins>,后台设的样式才能生效。
        source.select("s, strike").forEach(node -> node.tagName("del"));
        source.select("u").forEach(node -> node.tagName("ins"));
        source.select("img[src]").forEach(image -> {
            String src = image.attr("src").trim().toLowerCase(Locale.ROOT);
            if (src.startsWith("javascript:") || src.startsWith("vbscript:")) image.removeAttr("src");
        });
        Document.OutputSettings outputSettings = new Document.OutputSettings().prettyPrint(false);
        Document document = Jsoup.parseBodyFragment(Jsoup.clean(source.body().html(), "", safelist, outputSettings));
        document.outputSettings().prettyPrint(false);
        document.select("img").forEach(image -> {
            image.attr("style", appendResponsiveStyle(image.attr("style"), "max-width:100%;height:auto;"));
        });
talent-card-server/src/main/java/com/talentcard/server/business/agreement/MerchantAgreementService.java
@@ -63,17 +63,31 @@
    }
    private String sanitize(String html) {
        Safelist safelist = Safelist.relaxed()
                .removeTags("script")
                .addTags("span", "video", "source")
        // 图片 src 不走 Jsoup 的协议白名单:/uploads/xxx.png 这种相对地址会因为"不是 http/https"
        // 被整条删掉,商户端就拿不到图片。这里手动挡掉危险协议。
        Safelist safelist = new Safelist()
                .addTags("a", "abbr", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup",
                        "dd", "del", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i",
                        "img", "ins", "li", "ol", "p", "pre", "q", "small", "span", "strong", "sub", "sup",
                        "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", "video", "source")
                .addAttributes(":all", "style", "class")
                .addAttributes("a", "href", "title")
                .addAttributes("img", "src", "alt", "title", "width", "height")
                .addAttributes("video", "src", "controls", "poster", "width", "height")
                .addAttributes("source", "src", "type")
                .addProtocols("a", "href", "http", "https")
                .addProtocols("img", "src", "http", "https")
                .addProtocols("video", "src", "http", "https")
                .addProtocols("source", "src", "http", "https");
        Document document = Jsoup.parseBodyFragment(Jsoup.clean(html == null ? "" : html, safelist));
                .addProtocols("a", "href", "http", "https");
        Document source = Jsoup.parseBodyFragment(html == null ? "" : html);
        source.outputSettings().prettyPrint(false);
        // 小程序 rich-text 不认 <s>/<strike>/<u>,换成它支持的 <del>/<ins>,后台设的样式才能生效。
        source.select("s, strike").forEach(node -> node.tagName("del"));
        source.select("u").forEach(node -> node.tagName("ins"));
        source.select("img[src]").forEach(image -> {
            String src = image.attr("src").trim().toLowerCase(java.util.Locale.ROOT);
            if (src.startsWith("javascript:") || src.startsWith("vbscript:")) image.removeAttr("src");
        });
        Document.OutputSettings outputSettings = new Document.OutputSettings().prettyPrint(false);
        Document document = Jsoup.parseBodyFragment(Jsoup.clean(source.body().html(), "", safelist, outputSettings));
        document.outputSettings().prettyPrint(false);
        document.select("img").forEach(image -> image.attr("style", appendResponsiveStyle(image.attr("style"), "max-width:100%;height:auto;")));
        document.select("video").forEach(video -> video.attr("style", appendResponsiveStyle(video.attr("style"), "max-width:100%;height:auto;")));
        return document.body().html();
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java
@@ -3,9 +3,16 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.talentcard.server.common.BusinessException;
import java.util.List;
import org.mockito.ArgumentCaptor;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -56,4 +63,22 @@
                .contains("bo.refund_status is null or bo.refund_status = 0")
                .endsWith("as sales_count");
    }
    @Test
    void productAuditListOnlyKeepsTheLatestRecordPerProduct() {
        // 同一个团单修改后再次提交会插入新的审核记录;列表只保留每个团单最新的一条,
        // 否则同一个团单会出现多行、销量被重复统计。
        JdbcTemplate jdbc = mock(JdbcTemplate.class);
        AdminBusinessService adminService = new AdminBusinessService(jdbc);
        when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L);
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
        adminService.products(null, null, null, null, null, null, null, null, 1, 10);
        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
        verify(jdbc).queryForList(sql.capture(), any(Object[].class));
        assertThat(sql.getValue())
                .contains("from merchant_product_audit_record r")
                .contains("select max(newer.id) from merchant_product_audit_record newer");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/agreement/AgreementPortalServiceTests.java
New file
@@ -0,0 +1,37 @@
package com.talentcard.server.business.agreement;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class AgreementPortalServiceTests {
    @Mock
    private JdbcTemplate jdbc;
    @InjectMocks
    private AgreementPortalService service;
    @Test
    void keepsRelativeImageSourceAndConvertsUnsupportedStyleTags() {
        String html = "<p style=\"text-align: center;\"><s><strong>1人才用户协议</strong></s></p>"
                + "<p><img src=\"/uploads/a.png\" alt=\"二维码\"/></p>"
                + "<p><img src=\"javascript:alert(1)\"/></p>";
        String sanitized = service.sanitizeHtml(html);
        // 相对路径的图片地址必须保留(以前被 Jsoup 当成非法协议整条删掉,前端就只剩 alt 文字)。
        assertThat(sanitized).contains("src=\"/uploads/a.png\"");
        // 小程序 rich-text 不认 <s>,要转成它支持的 <del>,后台设的样式才生效。
        assertThat(sanitized).contains("<del>").doesNotContain("<s>");
        assertThat(sanitized).contains("text-align: center").contains("max-width:100%");
        // 危险协议仍然要挡掉。
        assertThat(sanitized).doesNotContain("javascript:");
    }
}