Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/IssuedCards.kt
androidlover5842 7b72dd45e3
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
Add card lookup by cardIndex
2026-01-28 20:05:53 +05:30

376 lines
15 KiB
Kotlin

package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.CardRevokeResponse
import com.android.trisolarisserver.controller.dto.IssueCardRequest
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.IssuedCard
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.IssuedCardRepo
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/room-stays")
class IssuedCards(
private val propertyAccess: PropertyAccess,
private val roomStayRepo: RoomStayRepo,
private val issuedCardRepo: IssuedCardRepo,
private val appUserRepo: AppUserRepo,
private val counterRepo: PropertyCardCounterRepo,
private val propertyRepo: PropertyRepo
) {
@PostMapping("/{roomStayId}/cards/prepare")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun prepare(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: CardPrepareRequest
): CardPrepareResponse {
val actor = requireIssueActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = nowForProperty(stay.property.timezone)
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val cardIndex = nextCardIndex(stay.property.id!!)
val payload = buildSector0Payload(stay.room.roomNumber, cardIndex, issuedAt, expiresAt)
return CardPrepareResponse(
cardIndex = cardIndex,
key = payload.key,
timeData = payload.timeData,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
sector3Block0 = encodeBlock(actor.name),
sector3Block1 = encodeBlock(actor.id?.toString()),
sector3Block2 = encodeBlock(null)
)
}
@PostMapping("/{roomStayId}/cards")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun issue(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: IssueCardRequest
): IssuedCardResponse {
val actor = requireIssueActor(propertyId, principal)
if (request.cardId.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardId required")
}
if (request.cardIndex <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
}
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone)
val expiresAt = parseOffset(request.expiresAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
if (!expiresAt.isAfter(issuedAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
}
val now = OffsetDateTime.now()
if (issuedCardRepo.existsActiveForRoomStay(roomStayId, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room stay")
}
if (issuedCardRepo.existsActiveForRoom(propertyId, stay.room.id!!, now)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
}
val card = IssuedCard(
property = stay.property,
room = stay.room,
roomStay = stay,
cardId = request.cardId.trim(),
cardIndex = request.cardIndex,
issuedAt = issuedAt,
expiresAt = expiresAt,
issuedBy = actor
)
return issuedCardRepo.save(card).toResponse()
}
@GetMapping("/{roomStayId}/cards")
fun list(
@PathVariable propertyId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<IssuedCardResponse> {
requireViewActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
.map { it.toResponse() }
}
@PostMapping("/cards/{cardIndex}/revoke")
@org.springframework.transaction.annotation.Transactional
fun revoke(
@PathVariable propertyId: UUID,
@PathVariable cardIndex: Int,
@AuthenticationPrincipal principal: MyPrincipal?
): CardRevokeResponse {
val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
requireRevokeActor(propertyId, principal, card.roomStay == null)
if (card.revokedAt == null) {
val now = nowForProperty(card.property.timezone)
card.revokedAt = now
card.expiresAt = now
issuedCardRepo.save(card)
}
val key = buildSector0Block2(card.room.roomNumber, card.cardIndex)
val timeData = buildSector0TimeData(card.issuedAt, card.expiresAt, key)
return CardRevokeResponse(timeData = timeData)
}
@GetMapping("/cards/{cardIndex}")
fun getCardByIndex(
@PathVariable propertyId: UUID,
@PathVariable cardIndex: Int,
@AuthenticationPrincipal principal: MyPrincipal?
): IssuedCardResponse {
requireCardAdminActor(propertyId, principal)
val card = issuedCardRepo.findByPropertyIdAndCardIndex(propertyId, cardIndex)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
return card.toResponse()
}
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun nowForProperty(timezone: String?): OffsetDateTime {
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
return OffsetDateTime.now(zone)
}
private fun encodeBlock(value: String?): String {
val raw = (value ?: "").padEnd(16).take(16)
val bytes = raw.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02X", b))
}
return sb.toString()
}
private fun requireMember(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
}
private fun requireViewActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireAnyRole(
propertyId,
principal.userId,
Role.STAFF,
Role.ADMIN,
Role.MANAGER,
Role.SUPERVISOR
)
}
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?, isTempCard: Boolean) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
if (isTempCard) {
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
} else {
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
}
}
private fun requireCardAdminActor(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
}
private fun nextCardIndex(propertyId: UUID): Int {
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
if (counter == null) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
property = property,
nextIndex = 10001
))
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
}
val current = counter!!.nextIndex
counter.nextIndex = current + 1
counter.updatedAt = OffsetDateTime.now()
counterRepo.save(counter)
return current
}
private fun buildSector0Payload(
roomNumber: Int,
cardIndex: Int,
issuedAt: OffsetDateTime,
expiresAt: OffsetDateTime
): Sector0Payload {
val key = buildSector0Block2(roomNumber, cardIndex)
val finalData = buildSector0TimeData(issuedAt, expiresAt, key)
return Sector0Payload(key, finalData)
}
private fun buildSector0TimeData(
issuedAt: OffsetDateTime,
expiresAt: OffsetDateTime,
key: String? = null
): String {
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
val checkSum = calculateChecksum((key ?: "") + newData)
return newData + checkSum
}
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val cardIdStr = cardID.toString().padStart(6, '0')
val guestIdStr = guestID.toString().padStart(6, '0')
val finalRoom = roomNumber.toString().padStart(2, '0')
return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000"
}
private fun formatDateComponents(time: OffsetDateTime): String {
val minute = time.minute.toString().padStart(2, '0')
val hour = time.hour.toString().padStart(2, '0')
val day = time.dayOfMonth.toString().padStart(2, '0')
val month = time.monthValue.toString().padStart(2, '0')
val year = time.year.toString().takeLast(2)
return "${minute}${hour}${day}${month}${year}"
}
private fun calculateChecksum(dataHex: String): String {
val data = hexStringToByteArray(dataHex)
var checksum = 0
for (byte in data) {
checksum = calculateByteChecksum(byte, checksum)
}
return String.format("%02X", checksum)
}
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
var checksum = currentChecksum
var b = byte.toInt()
for (i in 0 until 8) {
checksum = if ((checksum xor b) and 1 != 0) {
(checksum xor 0x18) shr 1 or 0x80
} else {
checksum shr 1
}
b = b shr 1
}
return checksum
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val len = hexString.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
+ Character.digit(hexString[i + 1], 16)).toByte()
}
return data
}
}
private data class Sector0Payload(
val key: String,
val timeData: String
)
private fun IssuedCard.toResponse(): IssuedCardResponse {
return IssuedCardResponse(
id = id!!,
propertyId = property.id!!,
roomId = room.id!!,
roomStayId = roomStay?.id,
cardId = cardId,
cardIndex = cardIndex,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
issuedByUserId = issuedBy?.id,
revokedAt = revokedAt?.toString()
)
}