| New file |
| | |
| | | # 商户进件详情:结算银行卡卡号面图片显示为空 |
| | | |
| | | 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:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路的代码未改动)。待转交支付链路负责人。 |
| | |
| | | */ |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | <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' |
| | |
| | | } |
| | | onShow(() => { |
| | | if (isParkingMerchant.value && parkingDetailLoaded) loadParkingMerchant() |
| | | // 酒店:从免费券页/核销后返回时刷新剩余次数,按钮在「出示免费券 / 出示人才码」之间切换。 |
| | | refreshHotelVoucherAvailability() |
| | | }) |
| | | const loadRemoteMerchant = () => { |
| | | if (isFuelMerchant.value || isParkingMerchant.value) { loadTransportMerchantDetail(); return } |
| | |
| | | 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) => { |
| | |
| | | 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 == '') { |
| | |
| | | <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> |
| | |
| | | <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) |
| | |
| | | const failInitialOnce = ref(false) |
| | | const failLoadMoreOnce = ref(false) |
| | | const forceEmpty = ref(false) |
| | | // 封面图加载失败时置空,交给灰底占位,避免出现破图。 |
| | | const replaceImage = (item: any) => { item.image = '' } |
| | | |
| | | onLoad((options: any) => { |
| | | // #ifdef MP-WEIXIN |
| | |
| | | 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 |
| | |
| | | 不覆盖的话标签不会收缩,只会顶出卡片、把时间挤没(省略号也不会出现)。 */ |
| | | .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> |
| | |
| | | <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> |
| | |
| | | const appointmentTime = ref('以专员通知为准') |
| | | const submittedAt = ref('') |
| | | const advisorName = ref('') |
| | | const advisorDepartment = ref('') |
| | | const advisorMobile = ref('') |
| | | |
| | | onLoad((options: any) => { |
| | |
| | | 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 }) |
| | |
| | | // 而原来这里会把所有非 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 + '/') |
| | |
| | | */ |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | </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> |
| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | const applyDetail = (detail: MerchantProductDetail) => { |
| | | productStatus.value = detail.status |
| | | productName.value = detail.productName |
| | | productAmount.value = detail.salePrice.toFixed(2) |
| | | detailText.value = detail.detailText |
| | |
| | | }) |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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' }) |
| | | }) |
| | | } |
| | | }) |
| | |
| | | .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; } |
| | |
| | | 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> |
| | |
| | | } |
| | | |
| | | 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(() => { |
| | |
| | | <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> </template>标签</span> |
| | | <el-input v-model="item.tag" placeholder="请输入" /> |
| | | </template> |
| | | </div> |
| | |
| | | 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()); |
| | | } |
| | | |
| | |
| | | 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) { |
| | |
| | | |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | |
| | | 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;")); |
| | | }); |
| | |
| | | } |
| | | |
| | | 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(); |
| | |
| | | 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; |
| | | |
| | |
| | | .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"); |
| | | } |
| | | } |
| New file |
| | |
| | | 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:"); |
| | | } |
| | | } |