import type { CheckInBadge, CheckInBadgeCode, CheckInBadgeDefinition, CheckInEligibility, CheckInLocationSnapshot, CheckInProfile, CheckInRecord, CheckInResult, } from './types' import type { GeoPoint } from '@/domain/poi' export const CHECK_IN_POINTS = 10 export const CHECK_IN_RADIUS_METERS = 200 export const CHECK_IN_MAX_ACCURACY_METERS = 100 export const CHECK_IN_BADGES: readonly CheckInBadgeDefinition[] = [ { code: 'explorer', name: '光明探索者', description: '完成首个真实定位点位打卡', icon: '01', requiredCheckIns: 1, }, { code: 'traveller', name: '光明旅行家', description: '累计打卡 2 个开放点位', icon: '02', requiredCheckIns: 2, }, { code: 'check_in_master', name: '全域打卡达人', description: '累计打卡 3 个开放点位', icon: '03', requiredCheckIns: 3, }, ] export class CheckInError extends Error { constructor( message: string, readonly code: 'already_checked_in' | 'invalid_poi' | 'invalid_location' | 'not_eligible', ) { super(message) this.name = 'CheckInError' } } export function createEmptyCheckInProfile(): CheckInProfile { return { points: 0, badges: [], checkIns: [] } } export function cloneCheckInProfile(profile: CheckInProfile): CheckInProfile { return { points: profile.points, badges: profile.badges.map(badge => ({ ...badge })), checkIns: profile.checkIns.map(record => ({ ...record })), } } function toRadians(value: number): number { return value * Math.PI / 180 } function isValidPoint(point: GeoPoint): boolean { return Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude >= -180 && point.longitude <= 180 && point.latitude >= -90 && point.latitude <= 90 } export function distanceBetweenMeters(left: GeoPoint, right: GeoPoint): number { if (!isValidPoint(left) || !isValidPoint(right)) return Number.NaN const earthRadiusMeters = 6_371_008.8 const latitudeDelta = toRadians(right.latitude - left.latitude) const longitudeDelta = toRadians(right.longitude - left.longitude) const leftLatitude = toRadians(left.latitude) const rightLatitude = toRadians(right.latitude) const haversine = Math.sin(latitudeDelta / 2) ** 2 + Math.cos(leftLatitude) * Math.cos(rightLatitude) * Math.sin(longitudeDelta / 2) ** 2 return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) } export function createCheckInRecordId(poiId: string, checkedInAt: string): string { return `check-in:${poiId}:${checkedInAt}` } export function evaluateCheckInEligibility( location: CheckInLocationSnapshot, poi: GeoPoint, allowedDistanceMeters = CHECK_IN_RADIUS_METERS, ): CheckInEligibility { if (!Number.isFinite(allowedDistanceMeters) || allowedDistanceMeters <= 0) { return { eligible: false, code: 'invalid_location', distanceMeters: null, allowedDistanceMeters: CHECK_IN_RADIUS_METERS, message: '点位打卡范围配置无效,请稍后重试', } } if (location.coordinateSystem !== 'GCJ02' || !isValidPoint(location)) { return { eligible: false, code: 'invalid_location', distanceMeters: null, allowedDistanceMeters, message: '定位结果无效,请重新获取当前位置', } } const accuracy = location.accuracy if (accuracy === null || !Number.isFinite(accuracy) || accuracy <= 0 || accuracy > CHECK_IN_MAX_ACCURACY_METERS) { return { eligible: false, code: 'low_accuracy', distanceMeters: null, allowedDistanceMeters, message: '当前位置精度不足,请到开阔处重新定位', } } const distanceMeters = distanceBetweenMeters(location, poi) if (!Number.isFinite(distanceMeters)) { return { eligible: false, code: 'invalid_location', distanceMeters: null, allowedDistanceMeters, message: '定位结果无效,请重新获取当前位置', } } if (distanceMeters - accuracy > allowedDistanceMeters) { return { eligible: false, code: 'too_far', distanceMeters, allowedDistanceMeters, message: `请到点位 ${Math.round(allowedDistanceMeters)} 米范围内再打卡`, } } if (distanceMeters + accuracy > allowedDistanceMeters) { return { eligible: false, code: 'uncertain', distanceMeters, allowedDistanceMeters, message: '当前位置接近打卡边界,请靠近点位后重新定位', } } return { eligible: true, code: 'eligible', distanceMeters, allowedDistanceMeters, message: '已进入点位打卡范围', } } export function hasCheckedIn(profile: CheckInProfile, poiId: string): boolean { const normalizedPoiId = poiId.trim() return Boolean(normalizedPoiId) && profile.checkIns.some(record => record.poiId === normalizedPoiId) } function badgesForCheckInCount(checkInCount: number, obtainedAt: string): CheckInBadge[] { return CHECK_IN_BADGES .filter(definition => checkInCount >= definition.requiredCheckIns) .map(definition => ({ code: definition.code, obtainedAt })) } export function applyCheckIn( profile: CheckInProfile, poi: { id: string, name: string } & GeoPoint, location: CheckInLocationSnapshot, now = Date.now(), allowedDistanceMeters = CHECK_IN_RADIUS_METERS, ): CheckInResult { const poiId = poi.id.trim() const poiName = poi.name.trim() if (!poiId || !poiName || !isValidPoint(poi)) throw new CheckInError('点位信息无效,请返回地图重新选择', 'invalid_poi') if (hasCheckedIn(profile, poiId)) throw new CheckInError('该点位已经打卡,本机不会重复计分', 'already_checked_in') const eligibility = evaluateCheckInEligibility(location, poi, allowedDistanceMeters) if (!eligibility.eligible || eligibility.distanceMeters === null) throw new CheckInError(eligibility.message, 'not_eligible') const checkedInAt = new Date(now).toISOString() const record: CheckInRecord = { id: createCheckInRecordId(poiId, checkedInAt), poiId, poiName, checkedInAt, pointsAwarded: CHECK_IN_POINTS, } const previousBadgeCodes = new Set(profile.badges.map(badge => badge.code)) const earnedBadges = badgesForCheckInCount(profile.checkIns.length + 1, checkedInAt) const newBadges = earnedBadges.filter(badge => !previousBadgeCodes.has(badge.code)) const nextProfile: CheckInProfile = { points: profile.points + CHECK_IN_POINTS, badges: [ ...profile.badges.map(badge => ({ ...badge })), ...newBadges, ], checkIns: [record, ...profile.checkIns.map(item => ({ ...item }))], } return { profile: nextProfile, record: { ...record }, newBadges: newBadges.map(badge => ({ ...badge })) } }