fix: complete merchant portal and login account setup
| | |
| | | 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' |
| | |
| | | |
| | | <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) |
| | |
| | | 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) |
| | |
| | | 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') |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | + encodeURIComponent(merchantName.value) |
| | | + '&hotelAddress=' + encodeURIComponent(merchantAddress.value) |
| | | + '&roomName=' + encodeURIComponent(item.name) |
| | | + '&merchantId=' + encodeURIComponent(merchantId.value) |
| | | + '&productId=' + encodeURIComponent('' + item.id) |
| | | + '&contactMobile=' + encodeURIComponent(merchantPhone.value) |
| | | }) |
| | | return |
| | | } |
| | |
| | | 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) => { |
| | |
| | | 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') |
| | |
| | | || 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) |
| | | )) |
| | |
| | | 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) |
| | |
| | | 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) { |
| | |
| | | <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位' : ''" /> |
| | |
| | | public class AdminBusinessService { |
| | | |
| | | private static final Set<String> MERCHANT_APP_LOGIN_TYPES = Set.of( |
| | | "餐饮服务", "景区服务", "购房优惠", "购车服务", "家电服务", "数码服务", |
| | | "文化服务", "电信服务", "口腔治疗保健", "加油服务" |
| | | "餐饮服务", "景区服务", "免费体检", "配偶推荐就业", "金融服务", "行政审批", "子女托管", |
| | | "酒店服务", "公寓租赁", "加油服务", "停车服务", "自助洗车", "购房优惠", "购车服务", |
| | | "家电服务", "数码服务", "文化服务", "电信服务", "口腔治疗保健" |
| | | ); |
| | | |
| | | private final JdbcTemplate jdbcTemplate; |
| | |
| | | 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)) { |
| | |
| | | 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()) { |
| | |
| | | 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); |
| | |
| | | String serviceLabel, |
| | | List<@Valid MerchantBusinessHourRequest> businessHours, |
| | | Byte canLoginMerchantApp, |
| | | @Size(max = 32) String loginAccount, |
| | | @Size(max = 64) String loginPassword |
| | | ) { |
| | | } |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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, |
| | |
| | | (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(); |
| | |
| | | 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; |
| New file |
| | |
| | | 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 |
| | | ); |