Files
gmTouringMiniApp/src/domain/check-in/check-in.ts
T

219 lines
6.7 KiB
TypeScript

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<CheckInBadgeCode>(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 })) }
}