shinnosuke
2026-08-19 ac44d2c693c433ed230652ed3f1f6516042309c5
fix: complete merchant portal and login account setup
1个文件已添加
10个文件已修改
130 ■■■■■ 已修改文件
rencaika/config/api.uts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/hotel-booking/hotel-booking.uvue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V116__backfill_parking_benefits_for_admin_merchants.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/api.uts
@@ -51,6 +51,7 @@
    if (name == 'appChildren') return '/api/app/children'
    if (name == 'appMedicalMerchantPackages') return '/api/app/medical-merchants/'
    if (name == 'appMedicalPackages') return '/api/app/medical-packages/'
    if (name == 'appServiceAppointmentApplications') return '/api/app/service-appointment-applications'
    if (name == 'appFuelMerchantDetail') return '/api/app/fuel-merchants/'
    if (name == 'appParkingMerchantDetail') return '/api/app/parking-merchants/'
    if (name == 'appParkingPlateSubmissions') return '/api/app/parking-plate-submissions'
rencaika/pages/hotel-booking/hotel-booking.uvue
@@ -64,7 +64,9 @@
<script setup lang="uts">
    import { safeBack } from '../../utils/navigation.uts'
    import { submitBusiness } from '../../services/business.uts'
    import { createIdempotencyKey } from '../../services/business.uts'
    import { submitServiceAppointment } from '../../services/portal.uts'
    import { getSession } from '../../services/session.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -81,6 +83,10 @@
    const hotelName = ref('全季酒店')
    const hotelAddress = ref('攀枝花东区核心商圈')
    const roomName = ref('商务大床房')
    const merchantId = ref('')
    const productId = ref('')
    const contactMobile = ref('')
    const talentId = ref('')
    const checkInDate = ref(todayDate)
    const checkOutDate = ref(getDateText(1))
    const draftCheckInDate = ref(todayDate)
@@ -101,6 +107,11 @@
        if (options != null && options['hotelName'] != null) hotelName.value = decodeURIComponent(options['hotelName'] as string)
        if (options != null && options['hotelAddress'] != null) hotelAddress.value = decodeURIComponent(options['hotelAddress'] as string)
        if (options != null && options['roomName'] != null) roomName.value = decodeURIComponent(options['roomName'] as string)
        if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
        if (options != null && options['productId'] != null) productId.value = options['productId'] as string
        if (options != null && options['contactMobile'] != null) contactMobile.value = decodeURIComponent(options['contactMobile'] as string)
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
    })
    const goBack = () => safeBack('/pages/stay-benefits/stay-benefits')
@@ -126,7 +137,13 @@
        checkOutDate.value = draftCheckOutDate.value
        isDatePanelVisible.value = false
    }
    const callMerchant = () => uni.makePhoneCall({ phoneNumber: '08123335555' })
    const callMerchant = () => {
        if (contactMobile.value.trim() == '') {
            uni.showToast({ title: '商户暂未配置联系电话', icon: 'none' })
            return
        }
        uni.makePhoneCall({ phoneNumber: contactMobile.value })
    }
    const submitBooking = () => {
        if (isSubmitting.value) return
        const today = getDateText(0)
@@ -134,8 +151,23 @@
            uni.showToast({ title: '请选择有效的入住和离店日期', icon: 'none' })
            return
        }
        if (talentId.value == '') {
            uni.showToast({ title: '未获取到人才信息,请重新登录', icon: 'none' })
            return
        }
        isSubmitting.value = true
        submitBusiness('hotelBooking', { hotelName: hotelName.value, hotelAddress: hotelAddress.value, roomName: roomName.value, checkInDate: checkInDate.value, checkOutDate: checkOutDate.value }, () => {
        const payload: any = {
            applicationType: 'HOTEL_RESERVATION',
            talentId: Number(talentId.value),
            merchantName: hotelName.value,
            contactMobile: contactMobile.value,
            productName: roomName.value,
            checkInDate: checkInDate.value,
            checkOutDate: checkOutDate.value
        }
        if (/^\d+$/.test(merchantId.value)) payload.merchantId = Number(merchantId.value)
        if (/^\d+$/.test(productId.value)) payload.productId = Number(productId.value)
        submitServiceAppointment(payload, createIdempotencyKey('hotel-booking'), () => {
            uni.redirectTo({ url: '/pages/hotel-booking-success/hotel-booking-success?hotelName=' + encodeURIComponent(hotelName.value) })
        }, (error) => {
            isSubmitting.value = false
rencaika/pages/merchant/merchant.uvue
@@ -733,6 +733,9 @@
                    + encodeURIComponent(merchantName.value)
                    + '&hotelAddress=' + encodeURIComponent(merchantAddress.value)
                    + '&roomName=' + encodeURIComponent(item.name)
                    + '&merchantId=' + encodeURIComponent(merchantId.value)
                    + '&productId=' + encodeURIComponent('' + item.id)
                    + '&contactMobile=' + encodeURIComponent(merchantPhone.value)
            })
            return
        }
rencaika/services/portal.uts
@@ -70,6 +70,7 @@
export const getMedicalPackages = (merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appMedicalMerchantPackages') + encodeURIComponent(merchantId) + '/packages', method: 'GET', skipAuth: true }, onSuccess, onError)
export const getMedicalPackage = (packageId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appMedicalPackages') + encodeURIComponent(packageId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getMedicalPackageItems = (packageId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appMedicalPackages') + encodeURIComponent(packageId) + '/items', method: 'GET', skipAuth: true }, onSuccess, onError)
export const submitServiceAppointment = (payload: any, idempotencyKey: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appServiceAppointmentApplications'), method: 'POST', skipAuth: true, data: payload, idempotencyKey: idempotencyKey }, onSuccess, onError)
export const getFuelMerchantDetail = (merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appFuelMerchantDetail') + encodeURIComponent(merchantId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const submitFuelMobileRegistration = (merchantId: string, talentId: string, benefitId: string, mobile: string, idempotencyKey: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appFuelMerchantDetail') + encodeURIComponent(merchantId) + '/registrations', method: 'POST', skipAuth: true, data: { talentId: Number(talentId), benefitId: Number(benefitId), mobile: mobile }, idempotencyKey: idempotencyKey }, onSuccess, onError)
export const getParkingMerchantDetail = (merchantId: string, talentCardId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
talent-card-admin/src/views/MerchantCreateView.vue
@@ -66,6 +66,7 @@
const rightsSaving = ref(false)
const accountChecking = ref(false)
const accountTaken = ref(false)
const merchantHasLoginAccount = ref(false)
const imageInputRef = ref<HTMLInputElement>()
const imageUploading = ref(false)
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage' | 'approvalWindow'>('merchant')
@@ -118,7 +119,7 @@
  || isApprovalMerchant.value
  || merchantAppLoginOptionalTypes.includes(normalizedMerchantType.value)
))
const requiresMerchantOperator = computed(() => !isEditMode.value && (
const requiresMerchantOperator = computed(() => (!isEditMode.value || !merchantHasLoginAccount.value) && (
  normalizedMerchantType.value === '景区服务'
  || (supportsMerchantAppLogin.value && Number(form.canLoginMerchantApp) === 1)
))
@@ -2079,8 +2080,8 @@
        longitude: Number(form.longitude || 0) || undefined,
        latitude: Number(form.latitude || 0) || undefined,
        canLoginMerchantApp: requiresMerchantOperator.value ? 1 : Number(form.canLoginMerchantApp ?? 1),
        loginAccount: !isEditMode.value ? accountForm.loginAccount : undefined,
        loginPassword: !isEditMode.value || updatingMerchantPassword ? accountForm.loginPassword : undefined,
        loginAccount: !isEditMode.value || !merchantHasLoginAccount.value ? accountForm.loginAccount : undefined,
        loginPassword: !isEditMode.value || updatingMerchantPassword || !merchantHasLoginAccount.value ? accountForm.loginPassword : undefined,
        groupBuyingEnabled: form.groupBuyingEnabled,
        usageInstructions: isParkingMerchant.value
            ? JSON.stringify(parkingAddresses.value)
@@ -2168,6 +2169,7 @@
            form.canLoginMerchantApp = Number(merchant.canLoginMerchantApp ?? 1)
            form.recommend = Number(merchant.isRecommend ?? 0)
            accountForm.loginAccount = merchant.loginAccount || ''
            merchantHasLoginAccount.value = Boolean(String(merchant.loginAccount || '').trim())
      if (isParkingMerchant.value) {
        parkingAddresses.value = parseJsonValue<ParkingAddress[]>(merchant.usageInstructions, [])
      } else if (isChildcareMerchant.value) {
@@ -2328,7 +2330,7 @@
            <el-switch v-model="form.canLoginMerchantApp" :active-value="1" :inactive-value="0" />
          </el-form-item>
          <el-form-item label="登录账号" prop="loginAccount" :required="requiresMerchantOperator" :error="accountTaken ? '登录账号已存在,请更换' : ''">
            <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode" :placeholder="isEditMode ? '未配置登录账号' : '仅支持英文和数字'" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" />
            <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode && merchantHasLoginAccount" :placeholder="isEditMode && !merchantHasLoginAccount ? '请输入登录账号' : (isEditMode ? '已配置登录账号' : '仅支持英文和数字')" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" />
          </el-form-item>
          <el-form-item label="登录密码" prop="loginPassword" :required="requiresMerchantOperator || (isEditMode && Boolean(accountForm.loginPassword))">
            <el-input v-model="accountForm.loginPassword" type="password" show-password :placeholder="isEditMode ? '留空则不修改,6至64位' : ''" />
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -30,8 +30,9 @@
public class AdminBusinessService {
    private static final Set<String> MERCHANT_APP_LOGIN_TYPES = Set.of(
            "餐饮服务", "景区服务", "购房优惠", "购车服务", "家电服务", "数码服务",
            "文化服务", "电信服务", "口腔治疗保健", "加油服务"
            "餐饮服务", "景区服务", "免费体检", "配偶推荐就业", "金融服务", "行政审批", "子女托管",
            "酒店服务", "公寓租赁", "加油服务", "停车服务", "自助洗车", "购房优惠", "购车服务",
            "家电服务", "数码服务", "文化服务", "电信服务", "口腔治疗保健"
    );
    private final JdbcTemplate jdbcTemplate;
@@ -388,6 +389,7 @@
                request.recommend() == null ? defaultRecommend(request.merchantType()) : request.recommend(),
                request.sortNo() == null ? 0 : request.sortNo());
        Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
        ensureParkingBenefit(id, request.merchantType());
        Long storeId = createDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
        if (hasMerchantAppAccess(request)) {
@@ -512,6 +514,7 @@
                request.status() == null ? 1 : request.status(),
                id);
        ensureUpdated(updated);
        ensureParkingBenefit(id, request.merchantType());
        Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
        if (request.loginPassword() != null && !request.loginPassword().isBlank()) {
@@ -523,11 +526,42 @@
                    set password_hash = ?
                    where merchant_id = ? and role_code = 'owner' and deleted = 0
                    """, MerchantPasswordHasher.encode(request.loginPassword()), id);
            if (passwordUpdated == 0) throw new BusinessException(ErrorCode.CONFLICT, "该商家未配置商户端登录账号");
            if (passwordUpdated == 0) {
                createMerchantOperator(id, request);
            }
        }
        if ((request.loginPassword() == null || request.loginPassword().isBlank()) && supportsMerchantAppLogin(request.merchantType())
                && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1)
                && (request.loginAccount() != null && !request.loginAccount().isBlank())) {
            createMerchantOperator(id, request);
        }
        return merchant(id);
    }
    private void createMerchantOperator(Long merchantId, MerchantUpdateRequest request) {
        if (request.loginAccount() == null || !request.loginAccount().trim().matches("^[A-Za-z0-9]{1,32}$")) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录账号只能包含英文字母和数字");
        }
        if (request.loginPassword() == null || request.loginPassword().length() < 6 || request.loginPassword().length() > 64) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录密码长度须为6至64位");
        }
        Integer occupied = jdbcTemplate.queryForObject("select count(*) from merchant_operator where account = ? and deleted = 0", Integer.class, request.loginAccount().trim());
        if (occupied != null && occupied > 0) throw new BusinessException(ErrorCode.CONFLICT, "登录账号已存在");
        jdbcTemplate.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status) values (?, ?, ?, ?, 'owner', 1)",
                merchantId, request.loginAccount().trim(), MerchantPasswordHasher.encode(request.loginPassword()), request.merchantName().trim());
    }
    private void ensureParkingBenefit(Long merchantId, String merchantType) {
        if (!"停车服务".equals(merchantType) && !"parking".equalsIgnoreCase(merchantType)) return;
        jdbcTemplate.update("""
                insert into transport_parking_benefit(merchant_id, talent_levels, benefit_text, action_mode, plate_form_mode, usage_instructions, rule_text, status, deleted)
                select ?, '', '停车权益', 'plate', 'withParking',
                       '登记车牌后可享受当前停车场的人才专属停车权益。',
                       '具体免费时长、适用时段和停车场范围以现场说明为准。', 1, 0
                where not exists (select 1 from transport_parking_benefit where merchant_id = ? and deleted = 0)
                """, merchantId, merchantId);
    }
    @Transactional
    public Map<String, Object> updateMerchantStatus(Long id, Byte status) {
        int updated = jdbcTemplate.update("update merchant set status = ? where deleted = 0 and id = ?", status, id);
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
@@ -36,6 +36,7 @@
        String serviceLabel,
        List<@Valid MerchantBusinessHourRequest> businessHours,
        Byte canLoginMerchantApp,
        @Size(max = 32) String loginAccount,
        @Size(max = 64) String loginPassword
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java
@@ -102,7 +102,7 @@
        return one("""
                select id, merchant_name, merchant_type, contact_mobile, address, cover_url, service_label, status, latitude, longitude
                from merchant
                where id = ? and merchant_type = 'fuel' and deleted = 0
                where id = ? and merchant_type in ('fuel', '加油服务') and deleted = 0
                """, merchantId);
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -415,12 +415,13 @@
        if (!List.of("fuel", "parking", "carWash").contains(type)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
        }
        String merchantWhere = " where deleted = 0 and merchant_type = ?";
        if (type.equals("parking")) {
            type = "停车服务";
        }if (type.equals("carWash")) {
            type = "自助洗车";
        }
        String requestedType = type;
        String merchantWhere = switch (type) {
            case "fuel" -> " where deleted = 0 and merchant_type in ('fuel', '加油服务')";
            case "parking" -> " where deleted = 0 and merchant_type in ('parking', '停车服务')";
            case "carWash" -> " where deleted = 0 and merchant_type in ('carWash', '自助洗车', '自助洗车服务')";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
        };
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       service_label, status, sort_no, longitude, latitude, business_status,
@@ -428,9 +429,10 @@
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
                from merchant
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size, type);
                "select count(*) from merchant" + merchantWhere, page, size);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("merchantType", requestedType);
            item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
            return item;
        }).toList();
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java
@@ -360,7 +360,9 @@
        String actualType = text(merchant, "merchantType", "merchant_type");
        boolean medicalType = "medical".equalsIgnoreCase(expectedType)
                && ("medical".equalsIgnoreCase(actualType) || "healthCheck".equalsIgnoreCase(actualType) || "免费体检".equals(actualType));
        if (expectedType != null && !medicalType && !expectedType.equalsIgnoreCase(actualType)) {
        boolean hotelType = "hotel".equalsIgnoreCase(expectedType)
                && ("hotel".equalsIgnoreCase(actualType) || "酒店".equals(actualType) || "酒店服务".equals(actualType));
        if (expectedType != null && !medicalType && !hotelType && !expectedType.equalsIgnoreCase(actualType)) {
            throw new BusinessException(ErrorCode.CONFLICT, message);
        }
        return merchant;
talent-card-server/src/main/resources/db/migration/V116__backfill_parking_benefits_for_admin_merchants.sql
New file
@@ -0,0 +1,14 @@
insert into transport_parking_benefit(
    merchant_id, talent_levels, benefit_text, action_mode, plate_form_mode,
    usage_instructions, rule_text, status, deleted
)
select m.id, '', '停车权益', 'plate', 'withParking',
       '登记车牌后可享受当前停车场的人才专属停车权益。',
       '具体免费时长、适用时段和停车场范围以现场说明为准。', 1, 0
from merchant m
where m.merchant_type in ('parking', '停车服务')
  and m.deleted = 0
  and not exists (
      select 1 from transport_parking_benefit b
      where b.merchant_id = m.id and b.deleted = 0
  );