fix: 修复地图坐标校验与解析

- 小程序兼容字符串坐标并自动纠正历史经纬度反向数据

- 后台机构资料页增加经纬度与地址联动校验

- org 模块保存机构资料时拦截非法坐标输入
This commit is contained in:
2026-03-21 11:17:02 +08:00
parent 74543cb9bf
commit 728847a8e3
3 changed files with 218 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
@@ -58,6 +59,7 @@ public class TenantOrgService {
profile.setCreatedBy(loginUser.userId());
profile.setDeleted(0);
}
CoordinateValue coordinates = parseCoordinates(request.hqAddress(), request.hqLatitude(), request.hqLongitude());
profile.setFirmName(request.firmName());
profile.setFirmShortName(request.firmShortName());
profile.setEnglishName(request.englishName());
@@ -68,8 +70,8 @@ public class TenantOrgService {
profile.setWebsiteUrl(request.websiteUrl());
profile.setWechatOfficialAccount(request.wechatOfficialAccount());
profile.setHqAddress(request.hqAddress());
profile.setHqLatitude(toBigDecimal(request.hqLatitude()));
profile.setHqLongitude(toBigDecimal(request.hqLongitude()));
profile.setHqLatitude(coordinates.latitude());
profile.setHqLongitude(coordinates.longitude());
profile.setUpdatedBy(loginUser.userId());
if (profile.getId() == null) {
orgFirmProfileMapper.insert(profile);
@@ -217,11 +219,51 @@ public class TenantOrgService {
);
}
private BigDecimal toBigDecimal(String value) {
if (value == null || value.isBlank()) {
return null;
private CoordinateValue parseCoordinates(String address, String latitude, String longitude) {
String latitudeText = normalizeCoordinate(latitude);
String longitudeText = normalizeCoordinate(longitude);
boolean hasLatitude = StringUtils.hasText(latitudeText);
boolean hasLongitude = StringUtils.hasText(longitudeText);
if (!hasLatitude && !hasLongitude) {
return new CoordinateValue(null, null);
}
if (!hasLatitude || !hasLongitude) {
throw new BusinessException("FIRM_COORDINATE_INVALID", "纬度和经度需同时填写");
}
if (!StringUtils.hasText(address)) {
throw new BusinessException("FIRM_ADDRESS_REQUIRED", "填写地图坐标时请同时填写详细地址");
}
BigDecimal latitudeValue = parseCoordinateValue(latitudeText, "纬度");
BigDecimal longitudeValue = parseCoordinateValue(longitudeText, "经度");
if (!isInRange(latitudeValue, -90, 90)) {
if (isInRange(latitudeValue, -180, 180) && isInRange(longitudeValue, -90, 90)) {
throw new BusinessException("FIRM_COORDINATE_INVALID", "纬度超出范围,请检查是否与经度填反");
}
throw new BusinessException("FIRM_COORDINATE_INVALID", "纬度范围应在 -90 到 90 之间");
}
if (!isInRange(longitudeValue, -180, 180)) {
throw new BusinessException("FIRM_COORDINATE_INVALID", "经度范围应在 -180 到 180 之间");
}
return new CoordinateValue(latitudeValue, longitudeValue);
}
private String normalizeCoordinate(String value) {
return value == null ? "" : value.trim();
}
private BigDecimal parseCoordinateValue(String value, String fieldLabel) {
try {
return new BigDecimal(value);
} catch (NumberFormatException exception) {
throw new BusinessException("FIRM_COORDINATE_INVALID", fieldLabel + "必须是数字");
}
}
private boolean isInRange(BigDecimal value, int min, int max) {
return value.compareTo(BigDecimal.valueOf(min)) >= 0 && value.compareTo(BigDecimal.valueOf(max)) <= 0;
}
private String resolveAssetUrl(Long assetId) {
@@ -270,6 +312,9 @@ public class TenantOrgService {
) {
}
private record CoordinateValue(BigDecimal latitude, BigDecimal longitude) {
}
public record PracticeAreaView(Long id, String areaCode, String areaName, Integer displayOrder, String areaStatus) {
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
@@ -19,6 +20,7 @@ function generateDeptCode() {
const loading = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const imageUploading = reactive({
logo: false,
hero: false,
@@ -72,6 +74,95 @@ const deptOptions = computed(() => departments.value.map(item => ({
value: item.id,
})))
function normalizeText(value: string | null | undefined) {
return typeof value === 'string' ? value.trim() : ''
}
function parseCoordinate(value: string | null | undefined) {
const normalized = normalizeText(value)
if (!normalized) {
return null
}
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function hasCoordinateInput() {
return Boolean(normalizeText(profile.hqLatitude) || normalizeText(profile.hqLongitude))
}
function getCoordinateValidationMessage() {
const latitudeText = normalizeText(profile.hqLatitude)
const longitudeText = normalizeText(profile.hqLongitude)
const hasLatitude = Boolean(latitudeText)
const hasLongitude = Boolean(longitudeText)
if (!hasLatitude && !hasLongitude) {
return ''
}
if (!hasLatitude || !hasLongitude) {
return '纬度和经度需同时填写'
}
const latitude = parseCoordinate(profile.hqLatitude)
if (latitude === null) {
return '纬度必须是数字'
}
const longitude = parseCoordinate(profile.hqLongitude)
if (longitude === null) {
return '经度必须是数字'
}
if (latitude < -90 || latitude > 90) {
if (latitude >= -180 && latitude <= 180 && longitude >= -90 && longitude <= 90) {
return '纬度超出范围,请检查是否与经度填反'
}
return '纬度范围应在 -90 到 90 之间'
}
if (longitude < -180 || longitude > 180) {
return '经度范围应在 -180 到 180 之间'
}
if (!normalizeText(profile.hqAddress)) {
return '填写地图坐标时请同时填写详细地址'
}
return ''
}
const profileRules: FormRules = {
firmName: [{ required: true, message: '请输入机构名称', trigger: 'blur' }],
hqAddress: [{
trigger: ['blur', 'change'],
validator: async () => {
if (hasCoordinateInput() && !normalizeText(profile.hqAddress)) {
throw new Error('填写地图坐标时请同时填写详细地址')
}
},
}],
hqLatitude: [{
trigger: ['blur', 'change'],
validator: async () => {
const message = getCoordinateValidationMessage()
if (message) {
throw new Error(message)
}
},
}],
hqLongitude: [{
trigger: ['blur', 'change'],
validator: async () => {
const message = getCoordinateValidationMessage()
if (message) {
throw new Error(message)
}
},
}],
}
async function loadData() {
loading.value = true
try {
@@ -135,6 +226,11 @@ function handleHeroUpload(options: UploadRequestOptions) {
}
async function saveProfile() {
const valid = await formRef.value?.validate().then(() => true).catch(() => false)
if (!valid) {
return
}
saving.value = true
try {
const saved = await tenantApi.saveFirmProfile(profile)
@@ -267,9 +363,15 @@ onMounted(() => {
</div>
</div>
<el-form label-position="top" class="card-form material-form">
<el-form
ref="formRef"
:model="profile"
:rules="profileRules"
label-position="top"
class="card-form material-form"
>
<div class="form-grid">
<el-form-item label="名称">
<el-form-item label="名称" prop="firmName">
<el-input v-model="profile.firmName" class="material-input" placeholder="完整的机构注册名称" />
</el-form-item>
<el-form-item label="简称">
@@ -284,13 +386,13 @@ onMounted(() => {
<el-form-item label="官网地址">
<el-input v-model="profile.websiteUrl" class="material-input" />
</el-form-item>
<el-form-item label="详细地址" class="form-grid__full">
<el-form-item label="详细地址" prop="hqAddress" class="form-grid__full">
<el-input v-model="profile.hqAddress" class="material-input" placeholder="完整的真实办公地址" />
</el-form-item>
<el-form-item label="纬度 (LAT)">
<el-form-item label="纬度 (LAT)" prop="hqLatitude">
<el-input v-model="profile.hqLatitude" class="material-input" placeholder="例如31.230416" />
</el-form-item>
<el-form-item label="经度 (LNG)">
<el-form-item label="经度 (LNG)" prop="hqLongitude">
<el-input v-model="profile.hqLongitude" class="material-input" placeholder="例如121.473701" />
</el-form-item>
<el-form-item label="机构简介" class="form-grid__full">

View File

@@ -8,8 +8,8 @@ interface OpenFirmResponse {
intro: string;
hotlinePhone: string;
hqAddress: string;
hqLatitude: number;
hqLongitude: number;
hqLatitude: number | string | null;
hqLongitude: number | string | null;
officeCount: number;
lawyerCount: number;
officeList: string[];
@@ -42,11 +42,62 @@ interface OpenCardDetailResponse {
specialties: string[];
firmName: string;
firmAddress: string;
firmLatitude: number;
firmLongitude: number;
firmLatitude: number | string | null;
firmLongitude: number | string | null;
}
function parseCoordinate(value: number | string | null | undefined): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function isLatitude(value: number | null): value is number {
return value !== null && value >= -90 && value <= 90;
}
function isLongitude(value: number | null): value is number {
return value !== null && value >= -180 && value <= 180;
}
function normalizeCoordinates(latitude: number | string | null | undefined, longitude: number | string | null | undefined): {
latitude: number;
longitude: number;
} {
const parsedLatitude = parseCoordinate(latitude);
const parsedLongitude = parseCoordinate(longitude);
if (isLatitude(parsedLatitude) && isLongitude(parsedLongitude)) {
return {
latitude: parsedLatitude,
longitude: parsedLongitude,
};
}
if (isLatitude(parsedLongitude) && isLongitude(parsedLatitude)) {
return {
latitude: parsedLongitude,
longitude: parsedLatitude,
};
}
return {
latitude: 0,
longitude: 0,
};
}
function toFirmInfo(payload: OpenFirmResponse): FirmInfo {
const coordinates = normalizeCoordinates(payload.hqLatitude, payload.hqLongitude);
return {
id: payload.name || 'firm',
name: payload.name || '',
@@ -54,8 +105,8 @@ function toFirmInfo(payload: OpenFirmResponse): FirmInfo {
intro: payload.intro || '',
hotlinePhone: payload.hotlinePhone || '',
hqAddress: payload.hqAddress || '',
hqLatitude: payload.hqLatitude || 0,
hqLongitude: payload.hqLongitude || 0,
hqLatitude: coordinates.latitude,
hqLongitude: coordinates.longitude,
officeCount: payload.officeCount || 0,
lawyerCount: payload.lawyerCount || 0,
heroImage: payload.heroImage || '',
@@ -114,6 +165,7 @@ export async function getLawyerDetail(cardId: string, sourceType = 'DIRECT', sha
const payload = await request<OpenCardDetailResponse>({
url: `/api/open/cards/${encodeURIComponent(cardId)}?${queryParts.join('&')}`,
});
const coordinates = normalizeCoordinates(payload.firmLatitude, payload.firmLongitude);
return {
firm: {
@@ -123,8 +175,8 @@ export async function getLawyerDetail(cardId: string, sourceType = 'DIRECT', sha
intro: '',
hotlinePhone: '',
hqAddress: payload.firmAddress || '',
hqLatitude: payload.firmLatitude || 0,
hqLongitude: payload.firmLongitude || 0,
hqLatitude: coordinates.latitude,
hqLongitude: coordinates.longitude,
officeCount: 0,
lawyerCount: 0,
heroImage: '',