Compare commits

..
7 Commits
36 changed files with 695 additions and 67 deletions
+31
View File
@@ -0,0 +1,31 @@
# Приложение предоставления API и логики формирования и отправки QR-кодов
Приложение предоставляет API для получения QR-кодов для дверей по идентификатору устройства, получение слотов для дверей по идентификатору партнера.
Реализует логику формирования и отправки QR-кодов, интеграцию с платежным шлюзом методом обратного вызова и методом опроса REST метода шлюза.
## Запуск приложения
Для приложения требуется БД и конфигурация подключения к БД.
При запуске приложения будут выполнены миграции в src/main/resources/db/changelog
Также для работы приложения нужен запущенный сервер авторизации
Для запуска приложения нужно выполнить команду
`gradle bootRun`
## Сборка приложения
`gradle jar`
## Задания по расписанию
- EmailOutboxSendJob - задание формирования и отправки QR-кода
- CleanExpiredNotPayedRentJob - задание по расписанию для опроса платежного шлюза и удаления просроченных не оплаченных бронирований
## Http методы
- POST /public/book - метод бронирования двери
- POST /public/book/callback - метод для обратного вызова шлюза
- GET /qr - метод получения QR-кодов для определенного устройства(информация об устройстве берется из токена)
- POST /qr-used/{qrId} - метод пометки о том, что qr-код был использован(для сбора метрик)
- GET /public/slots/{partnerId} - метод получения текущего расписания дверей для партнера
+4
View File
@@ -24,7 +24,11 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.nayuki:qrcodegen:1.8.0")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.liquibase:liquibase-core") implementation("org.liquibase:liquibase-core")
implementation("ru.vyatsu:qr-access-hardware-contract:1.0.0") implementation("ru.vyatsu:qr-access-hardware-contract:1.0.0")
@@ -2,8 +2,12 @@ package ru.vyatsu.qr_access_api
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
//TODO: Сделать ретраи везде, где в блок-схемах они указаны
@SpringBootApplication @SpringBootApplication
@EnableScheduling
class QrAccessApiApplication class QrAccessApiApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {
@@ -0,0 +1,23 @@
package ru.vyatsu.qr_access_api.booking.controller
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import ru.vyatsu.qr_access_api.booking.request.BookCallbackRequest
import ru.vyatsu.qr_access_api.booking.service.BookingService
import ru.vyatsu.qr_access_api.booking.request.BookRequest
import ru.vyatsu.qr_access_api.booking.request.BookResponse
@RestController
class BookingController(private val service: BookingService) {
// TODO: Убрать /public, так как эти методы должны быть закрыты авторизацией client_credential и корсами
@PostMapping("/public/book")
fun book(@RequestBody request: BookRequest): BookResponse {
return BookResponse(service.book(request))
}
@PostMapping("/public/book/callback")
fun bookCallback(@RequestBody request: BookCallbackRequest) {
return service.bookPayed(request.rentId)
}
}
@@ -0,0 +1,18 @@
package ru.vyatsu.qr_access_api.booking.job
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import ru.vyatsu.qr_access_api.booking.repository.BookingRepository
import ru.vyatsu.qr_access_api.common.logger
import java.time.LocalDateTime
@Service
class CleanExpiredNotPayedRentJob(private val repository: BookingRepository) {
@Scheduled(cron = "0 */5 * * * *")
fun clean() {
val deletedCount =
repository.deleteRentByDateCreatedLessThen(LocalDateTime.now().minusMinutes(10))
logger().info("CleanExpiredNotPayedRentJob deleted {} rents", deletedCount)
}
}
@@ -0,0 +1,106 @@
package ru.vyatsu.qr_access_api.booking.repository
import org.springframework.dao.EmptyResultDataAccessException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.support.GeneratedKeyHolder
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
import ru.vyatsu.apis.NotFoundException
import ru.vyatsu.qr_access_api.booking.repository.entity.RentWithEmail
import java.sql.Date
import java.sql.Statement
import java.sql.Time
import java.sql.Timestamp
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.util.*
private const val ADD_BOOKING_INFO_QUERY =
"insert into rents(id, start_time, end_time, client_id, date, door_id, qr_code, payed, date_created) values (?, ? ,?, ?, ?, ?, ?, false, CURRENT_TIMESTAMP) RETURNING id"
private const val CREATE_NEW_CLIENT =
"insert into clients(id, email, email_is_confirmed) values (?, ?, false) RETURNING id"
private const val FIND_CLIENT_BY_EMAIL = "select id from clients where email = ?"
private const val MARK_RENT_AS_PAYED_QUERY = "update rents set payed=true where id = ?"
@Repository
class BookingRepository(val template: JdbcTemplate) {
@Transactional
fun book(doorId: String, date: LocalDate, startTime: LocalTime, endTime: LocalTime, clientEmail: String): String {
val clientIdHolder = GeneratedKeyHolder()
val rentIdHolder = GeneratedKeyHolder()
var clientId: String? = null
try {
clientId = template.queryForObject(
FIND_CLIENT_BY_EMAIL,
{ rs, _ -> rs.getString("id") },
clientEmail
)
} catch (_: EmptyResultDataAccessException) {
}
if (clientId == null) {
template.update({
val stmt = it.prepareStatement(CREATE_NEW_CLIENT, Statement.RETURN_GENERATED_KEYS)
stmt.setString(1, UUID.randomUUID().toString())
stmt.setString(2, clientEmail)
stmt
}, clientIdHolder)
clientId = clientIdHolder.getKeyAs(String::class.java)
}
if (clientId == null) throw RuntimeException("clientId is null even after insert, booking cannot be continued")
val insertedRows = template.update({
val stmt = it.prepareStatement(ADD_BOOKING_INFO_QUERY, Statement.RETURN_GENERATED_KEYS)
stmt.setString(1, UUID.randomUUID().toString())
stmt.setTime(2, Time.valueOf(startTime))
stmt.setTime(3, Time.valueOf(endTime))
stmt.setString(4, clientId)
stmt.setDate(5, Date.valueOf(date))
stmt.setString(6, doorId)
stmt.setString(7, UUID.randomUUID().toString())
stmt
}, rentIdHolder)
val insertedRentId = rentIdHolder.getKeyAs(String::class.java)
if (insertedRows <= 0 || insertedRentId == null) throw RuntimeException(
"Inserted rows number is invalid: %d. Have to be more then 0".format(
insertedRows
)
)
return insertedRentId
}
fun findRentWithClientById(id: String): RentWithEmail {
return template.queryForObject(
"select r.qr_code, c.email from rents r join clients c on (c.id = r.client_id) where r.id = ?",
{ rs, _ -> RentWithEmail(rs.getString("qr_code"), rs.getString("email")) },
id
) ?: throw NotFoundException("Cannot find rent with id %s".format(id))
}
fun markBookingPayed(rentId: String) {
val updatedAmount = template.update {
val stmt = it.prepareStatement(MARK_RENT_AS_PAYED_QUERY)
stmt.setString(1, rentId)
stmt
}
if (updatedAmount == 0)
throw NotFoundException("Cannot find rent with id %s".format(rentId))
}
fun deleteRentByDateCreatedLessThen(dateTime: LocalDateTime): Int {
return template.update {
val stmt = it.prepareStatement("delete from rents where date_created <= ? and payed = false")
stmt.setTimestamp(1, Timestamp.valueOf(dateTime))
stmt
}
}
}
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.booking.repository.entity
data class RentWithEmail(val qrCode: String, val email: String)
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.booking.request
data class BookCallbackRequest(val rentId: String)
@@ -0,0 +1,10 @@
package ru.vyatsu.qr_access_api.booking.request
import java.time.LocalDateTime
data class BookRequest(
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val doorId: String,
val clientEmail: String
)
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.booking.request
data class BookResponse(val rentId: String)
@@ -0,0 +1,37 @@
package ru.vyatsu.qr_access_api.booking.service
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.vyatsu.qr_access_api.booking.repository.BookingRepository
import ru.vyatsu.qr_access_api.booking.request.BookRequest
import ru.vyatsu.qr_access_api.common.exception.ValidationException
import ru.vyatsu.qr_access_api.email.repository.EmailRepository
@Service
class BookingService(val bookingRepository: BookingRepository, val emailRepository: EmailRepository) {
fun book(request: BookRequest): String {
// TODO: Произвести еще валидации, если нужны
val date = request.startDateTime.toLocalDate()
if (date != request.endDateTime.toLocalDate()) {
throw ValidationException(
"startDateTime, endDateTime",
"startDateTime and endDateTime have to have the same day"
)
}
val startTime = request.startDateTime.toLocalTime()
val endTime = request.endDateTime.toLocalTime()
return bookingRepository.book(request.doorId, date, startTime, endTime, request.clientEmail)
}
@Transactional
fun bookPayed(bookId: String) {
bookingRepository.markBookingPayed(bookId)
val (qrCode, email) = bookingRepository.findRentWithClientById(bookId)
val additionalData = JsonNodeFactory.instance.objectNode()
additionalData.set<ObjectNode>("qr_code", JsonNodeFactory.instance.textNode(qrCode))
emailRepository.createEmailOutboxRecord(email, "qr_code", additionalData)
}
}
@@ -0,0 +1,8 @@
package ru.vyatsu.qr_access_api.common
import org.slf4j.Logger
import org.slf4j.LoggerFactory
inline fun <reified T> T.logger(): Logger {
return LoggerFactory.getLogger(T::class.java)
}
@@ -0,0 +1,27 @@
package ru.vyatsu.qr_access_api.common.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.JavaMailSenderImpl
@Configuration
class EmailConfig {
@Bean
fun mailSender(): JavaMailSender {
val mailSender = JavaMailSenderImpl()
mailSender.host = "smtp.yandex.ru"
mailSender.port = 587
mailSender.username = "kashiuno@yandex.ru"
mailSender.password = "qmpEMP262049!!!?EEWChaosMeteor"
val props = mailSender.javaMailProperties
props["mail.transport.protocol"] = "smtp"
props["mail.smtp.auth"] = "true"
props["mail.smtp.starttls.enable"] = "true"
props["mail.debug"] = "true"
return mailSender
}
}
@@ -0,0 +1,4 @@
package ru.vyatsu.qr_access_api.common.exception
class ValidationException(val fieldName: String, override val message: String) :
RuntimeException("fieldName: %s -- message: %s".format(fieldName, message))
@@ -2,19 +2,40 @@ package ru.vyatsu.qr_access_api.config
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.security.config.Customizer import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity(debug = true)
class SecurityConfig { class SecurityConfig {
@Bean @Bean
fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http.authorizeHttpRequests { it.anyRequest().authenticated() } return http.authorizeHttpRequests {
it.requestMatchers("/public/**", "/error").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt(Customizer.withDefaults()) } .oauth2ResourceServer { it.jwt(Customizer.withDefaults()) }
.cors { c ->
c.configurationSource {
val config = CorsConfiguration()
config.addAllowedOrigin("http://localhost:3000")
config.allowedMethods = listOf(
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
config.allowedHeaders = listOf(
HttpHeaders.CONTENT_TYPE
)
config
}
}
.csrf { c -> c.disable() }
.build() .build()
} }
} }
@@ -0,0 +1,71 @@
package ru.vyatsu.qr_access_api.email.job
import io.nayuki.qrcodegen.QrCode
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import ru.vyatsu.qr_access_api.common.logger
import ru.vyatsu.qr_access_api.email.repository.EmailRepository
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.*
import javax.imageio.ImageIO
@Service
class EmailOutboxSendJob(val repository: EmailRepository, val mailSender: JavaMailSender) {
@Scheduled(cron = "*/5 * * * * *")
fun sendEmails() {
val messagesToSend = repository.findRecordsToSendWithBlock()
messagesToSend.forEach {
val msg = mailSender.createMimeMessage()
val helper = MimeMessageHelper(msg, true)
helper.setFrom("kashiuno@yandex.ru")
helper.setTo(it.email)
helper.setSubject("Приобретение qr-кода")
helper.setText("Вы приобрели проход в коворкинг на нашем сайте. qr-код во вложении. QR-код нужно приложить к сканеру соответствующей двери и она откроется")
val qrCode = it.additionalData.get("qr_code")
val code = QrCode.encodeText(qrCode.asText(), QrCode.Ecc.MEDIUM)
val image = toImage(code, 3, 2)
val os = ByteArrayOutputStream()
ImageIO.write(image, "png", os)
val imageIS = ByteArrayInputStream(os.toByteArray())
helper.addAttachment("qr_code.png") { imageIS }
}
val recordsWasDeletedCount = repository.deleteRecordsToSend(messagesToSend.map { it.id })
logger().info("Emails sent {}", recordsWasDeletedCount)
}
companion object {
private fun toImage(qr: QrCode, scale: Int, border: Int, lightColor: Int, darkColor: Int): BufferedImage {
Objects.requireNonNull(qr)
require(!(scale <= 0 || border < 0)) { "Value out of range" }
require(!(border > Int.MAX_VALUE / 2 || qr.size + border * 2L > Int.MAX_VALUE / scale)) { "Scale or border too large" }
val result = BufferedImage(
(qr.size + border * 2) * scale,
(qr.size + border * 2) * scale,
BufferedImage.TYPE_INT_RGB
)
for (y in 0 until result.height) {
for (x in 0 until result.width) {
val color = qr.getModule(x / scale - border, y / scale - border)
result.setRGB(x, y, if (color) darkColor else lightColor)
}
}
return result
}
fun toImage(qr: QrCode?, scale: Int, border: Int): BufferedImage {
return toImage(qr!!, scale, border, 0xFFFFFF, 0x000000)
}
}
}
@@ -0,0 +1,54 @@
package ru.vyatsu.qr_access_api.email.repository
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.jdbc.core.BatchPreparedStatementSetter
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import ru.vyatsu.qr_access_api.email.repository.entity.EmailOutbox
import java.sql.PreparedStatement
import java.util.*
private const val INSERT_EMAIL_OUTBOX_RECORD =
"insert into email_outbox(id, email, template, additional_info) values (?, ?, ?, (to_json(?::json)))"
@Repository
class EmailRepository(private val template: JdbcTemplate, private val om: ObjectMapper) {
fun createEmailOutboxRecord(email: String, msgTemplate: String, additionalInfo: JsonNode) {
val insertedCount = template.update {
val stmt = it.prepareStatement(INSERT_EMAIL_OUTBOX_RECORD)
stmt.setString(1, UUID.randomUUID().toString())
stmt.setString(2, email)
stmt.setString(3, msgTemplate)
stmt.setObject(4, om.writeValueAsString(additionalInfo))
stmt
}
if (insertedCount != 1)
throw RuntimeException("Inserted rows should be equals to 1")
}
fun findRecordsToSendWithBlock(): List<EmailOutbox> {
return template.query("select id, email, template, additional_info from email_outbox for update", { rs, _ ->
EmailOutbox(
rs.getString("id"),
rs.getString("email"),
rs.getString("template"),
om.readTree(rs.getString("additional_info"))
)
})
}
fun deleteRecordsToSend(ids: List<String>): Int {
val query = "delete from email_outbox where id = ?"
return template.batchUpdate(query, object : BatchPreparedStatementSetter {
override fun setValues(ps: PreparedStatement, i: Int) {
ps.setString(1, ids[i])
}
override fun getBatchSize(): Int = ids.size
})
.sum()
}
}
@@ -0,0 +1,5 @@
package ru.vyatsu.qr_access_api.email.repository.entity
import com.fasterxml.jackson.databind.JsonNode
data class EmailOutbox(val id: String, val email: String, val template: String, val additionalData: JsonNode)
@@ -1,10 +1,10 @@
package ru.vyatsu.qr_access_api.controller package ru.vyatsu.qr_access_api.qr.controller
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.vyatsu.apis.QrApi import ru.vyatsu.apis.QrApi
import ru.vyatsu.models.QrCodesResponse import ru.vyatsu.models.QrCodesResponse
import ru.vyatsu.qr_access_api.service.QrSyncService import ru.vyatsu.qr_access_api.qr.service.QrSyncService
@RestController @RestController
class QrSyncController(val syncService: QrSyncService) : QrApi { class QrSyncController(val syncService: QrSyncService) : QrApi {
@@ -1,4 +1,4 @@
package ru.vyatsu.qr_access_api.controller package ru.vyatsu.qr_access_api.qr.controller
import org.springframework.http.HttpStatusCode import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -1,16 +1,17 @@
package ru.vyatsu.qr_access_api.repository package ru.vyatsu.qr_access_api.qr.repository
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import ru.vyatsu.models.QrCode import ru.vyatsu.models.QrCode
import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.* import java.util.*
private const val GET_ACTUAL_QRS_BY_UNIT_ID = """ private const val GET_ACTUAL_QRS_BY_UNIT_ID = """
SELECT q.start_date_time, q.end_date_time, q.door_id, q.key_code FROM qrs q SELECT r.start_time, r.end_time, r.date, r.door_id, r.qr_code FROM rents r
JOIN doors d ON (d.id = q.door_id) JOIN doors d ON (d.id = r.door_id)
JOIN oauth2_registered_client c ON (c.client_id = d.unit_id) JOIN oauth2_registered_client c ON (c.client_id = d.unit_id)
WHERE c.client_id = ? AND q.start_date_time <= CURRENT_TIMESTAMP AND q.end_date_time >= CURRENT_TIMESTAMP WHERE c.client_id = ? AND r.start_time <= CURRENT_TIMESTAMP AND r.end_time >= CURRENT_TIMESTAMP
""" """
@Repository @Repository
@@ -24,10 +25,10 @@ class QrRepository(private val template: JdbcTemplate) {
stmt stmt
}, { rs, _ -> }, { rs, _ ->
QrCode( QrCode(
rs.getTimestamp("start_date_time").toLocalDateTime().atZone(ZoneId.systemDefault()).toOffsetDateTime(), LocalDateTime.of(rs.getDate("date").toLocalDate(), rs.getTime("start_time").toLocalTime()).atZone(ZoneId.systemDefault()).toOffsetDateTime(),
rs.getTimestamp("end_date_time").toLocalDateTime().atZone(ZoneId.systemDefault()).toOffsetDateTime(), LocalDateTime.of(rs.getDate("date").toLocalDate(), rs.getTime("end_time").toLocalTime()).atZone(ZoneId.systemDefault()).toOffsetDateTime(),
UUID.fromString(rs.getString("door_id")), UUID.fromString(rs.getString("door_id")),
UUID.fromString(rs.getString("key_code")) UUID.fromString(rs.getString("qr_code"))
) )
}) })
} }
@@ -1,9 +1,9 @@
package ru.vyatsu.qr_access_api.service package ru.vyatsu.qr_access_api.qr.service
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import ru.vyatsu.models.QrCode import ru.vyatsu.models.QrCode
import ru.vyatsu.qr_access_api.repository.QrRepository import ru.vyatsu.qr_access_api.qr.repository.QrRepository
@Service @Service
class QrSyncService(val qrRepository: QrRepository) { class QrSyncService(val qrRepository: QrRepository) {
@@ -0,0 +1,15 @@
package ru.vyatsu.qr_access_api.slots.controller
import org.springframework.web.bind.annotation.*
import ru.vyatsu.qr_access_api.slots.response.SlotResponse
import ru.vyatsu.qr_access_api.slots.service.SlotService
@RestController
@RequestMapping("public")
class SlotsController(val service: SlotService) {
@GetMapping("/slots/{partnerId}")
fun getSlots(@PathVariable partnerId: String): SlotResponse {
return service.getAllSlotsByPartner(partnerId)
}
}
@@ -0,0 +1,80 @@
package ru.vyatsu.qr_access_api.slots.repository
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import ru.vyatsu.qr_access_api.slots.repository.entity.*
const val FIND_DOORS_SCHEDULE_BY_PARTNER_ID =
"select d.id, d.description, d.count, s.date, s.start_time, s.end_time from oauth2_registered_client u join doors d on (d.unit_id = u.client_id) join schedule s on (d.id = s.door_id) where u.partner_id = ?"
const val FIND_RENT_DOORS_BY_PARTNER_ID =
"select d.id, r.date, r.start_time, r.end_time from oauth2_registered_client u join doors d on (d.unit_id = u.client_id) join rents r on (r.door_id = d.id) where u.partner_id = ? order by r.start_time asc"
@Repository
class SlotRepository(private val jdbc: JdbcTemplate) {
fun findAllDoorsWithScheduleByPartnerId(partnerId: String): Collection<DoorWithSchedule> {
val doors: MutableMap<String, DoorWithSchedule> = mutableMapOf()
jdbc.query({
val stmt = it.prepareStatement(FIND_DOORS_SCHEDULE_BY_PARTNER_ID)
stmt.setString(1, partnerId)
stmt
}, { rs ->
val id = rs.getString("id")
doors[id]?.also {
it.schedule.add(
ScheduleEntry(
rs.getTime("start_time").toLocalTime(),
rs.getTime("end_time").toLocalTime(),
rs.getDate("date").toLocalDate()
)
)
} ?: run {
doors[id] = DoorWithSchedule(
id, rs.getString("description"), rs.getInt("count"), mutableListOf(
ScheduleEntry(
rs.getTime("start_time").toLocalTime(),
rs.getTime("end_time").toLocalTime(),
rs.getDate("date").toLocalDate()
)
)
)
}
})
return doors.values
}
fun findRentDateTimesByPartnerId(partnerId: String): DoorRent {
val doors = DoorRent(mutableMapOf())
jdbc.query({
val stmt = it.prepareStatement(FIND_RENT_DOORS_BY_PARTNER_ID)
stmt.setString(1, partnerId)
stmt
}, { rs ->
val id = rs.getString("id")
val date = rs.getDate("date").toLocalDate()
doors.dates[id]?.also { d ->
d.rentDate[date]?.also { times ->
times.add(RentTime(rs.getTime("start_time").toLocalTime(), rs.getTime("end_time").toLocalTime()))
} ?: run {
d.rentDate[date] = mutableListOf(
RentTime(
rs.getTime("start_time").toLocalTime(),
rs.getTime("end_time").toLocalTime()
)
)
}
} ?: run {
doors.dates[id] = RentDate(
mutableMapOf(
date to mutableListOf(
RentTime(
rs.getTime("start_time").toLocalTime(),
rs.getTime("end_time").toLocalTime()
)
)
)
)
}
})
return doors
}
}
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.slots.repository.entity
data class DoorRent(val dates: MutableMap<String, RentDate>)
@@ -0,0 +1,8 @@
package ru.vyatsu.qr_access_api.slots.repository.entity
data class DoorWithSchedule(
val id: String,
val description: String,
val count: Int,
val schedule: MutableList<ScheduleEntry>
)
@@ -0,0 +1,5 @@
package ru.vyatsu.qr_access_api.slots.repository.entity
import java.time.LocalDate
data class RentDate(val rentDate: MutableMap<LocalDate, MutableList<RentTime>>)
@@ -0,0 +1,5 @@
package ru.vyatsu.qr_access_api.slots.repository.entity
import java.time.LocalTime
data class RentTime(val startTime: LocalTime, val endTime: LocalTime)
@@ -0,0 +1,6 @@
package ru.vyatsu.qr_access_api.slots.repository.entity
import java.time.LocalDate
import java.time.LocalTime
data class ScheduleEntry(val startTime: LocalTime, val endTime: LocalTime, val date: LocalDate)
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.slots.response
data class Door(val id: String, val description: String, val slots: List<Slot>)
@@ -0,0 +1,6 @@
package ru.vyatsu.qr_access_api.slots.response
import java.time.LocalDate
import java.time.LocalTime
data class Slot(val startTime: LocalTime, val endTime: LocalTime, val date: LocalDate, val status: SlotStatus)
@@ -0,0 +1,3 @@
package ru.vyatsu.qr_access_api.slots.response
data class SlotResponse(val doors: List<Door>)
@@ -0,0 +1,5 @@
package ru.vyatsu.qr_access_api.slots.response
enum class SlotStatus {
FREE, BOOKED, OUT_OF_WORKING_TIME
}
@@ -0,0 +1,51 @@
package ru.vyatsu.qr_access_api.slots.service
import org.springframework.stereotype.Service
import ru.vyatsu.qr_access_api.slots.repository.SlotRepository
import ru.vyatsu.qr_access_api.slots.response.Door
import ru.vyatsu.qr_access_api.slots.response.Slot
import ru.vyatsu.qr_access_api.slots.response.SlotResponse
import ru.vyatsu.qr_access_api.slots.response.SlotStatus
import java.time.LocalDate
import java.time.LocalTime
@Service
class SlotService(private val repository: SlotRepository) {
fun getAllSlotsByPartner(partnerId: String): SlotResponse {
// TODO: Учитывать количество мест и переписать логику
val doorsWithSchedule = repository.findAllDoorsWithScheduleByPartnerId(partnerId)
val doorRents = repository.findRentDateTimesByPartnerId(partnerId)
val doors: MutableList<Door> = mutableListOf()
doorsWithSchedule.forEach { d ->
val slots: MutableList<Slot> = mutableListOf()
d.schedule.forEach { sch ->
val rentTimes = doorRents.dates[d.id]?.rentDate?.get(sch.date) ?: listOf()
createIntermediateSlot(LocalTime.MIN, sch.startTime, sch.date, SlotStatus.OUT_OF_WORKING_TIME)
?.also { slots.add(it) }
rentTimes.forEach { rt ->
val lastSlot: Slot? = if (slots.lastIndex == -1) null else slots.last()
createIntermediateSlot(lastSlot?.endTime, rt.startTime, sch.date, SlotStatus.FREE)
?.also { slots.add(it) }
slots.add(Slot(rt.startTime, rt.endTime, sch.date, SlotStatus.BOOKED))
}
var lastSlot: Slot? = if (slots.lastIndex == -1) null else slots.last()
createIntermediateSlot(lastSlot?.endTime, sch.endTime, sch.date, SlotStatus.FREE)
?.also { slots.add(it) }
lastSlot = if (slots.lastIndex == -1) null else slots.last()
createIntermediateSlot(lastSlot?.endTime, LocalTime.MAX, sch.date, SlotStatus.OUT_OF_WORKING_TIME)
?.also { slots.add(it) }
}
doors.add(Door(d.id, d.description, slots))
}
return SlotResponse(doors)
}
private fun createIntermediateSlot(
startTime: LocalTime?,
endTime: LocalTime,
date: LocalDate,
status: SlotStatus
): Slot? {
return if (startTime != null && startTime != endTime) Slot(startTime, endTime, date, status) else null
}
}
@@ -128,14 +128,38 @@ databaseChangeLog:
constraints: constraints:
nullable: false nullable: false
- column: - column:
name: parent_door_id name: parent_door_ids
type: TEXT type: TEXT
constraints: constraints:
nullable: true nullable: true
foreignKeyName: FK_parent_doors - column:
references: doors(id) name: price
type: DECIMAL(12, 2)
constraints:
nullable: false
- createTable: - createTable:
tableName: rent tableName: clients
columns:
- column:
constraints:
nullable: false
primaryKey: true
primaryKeyName: PK_clients
name: id
type: TEXT
- column:
constraints:
nullable: false
unique: true
name: email
type: TEXT
- column:
constraints:
nullable: false
name: email_is_confirmed
type: BOOLEAN
- createTable:
tableName: rents
columns: columns:
- column: - column:
constraints: constraints:
@@ -160,7 +184,7 @@ databaseChangeLog:
constraints: constraints:
nullable: false nullable: false
foreignKeyName: FK_rent_clients foreignKeyName: FK_rent_clients
references: rent(id) references: clients(id)
- column: - column:
name: date name: date
type: DATE type: DATE
@@ -188,26 +212,6 @@ databaseChangeLog:
type: TIMESTAMP WITH TIME ZONE type: TIMESTAMP WITH TIME ZONE
constraints: constraints:
nullable: false nullable: false
- createTable:
tableName: clients
columns:
- column:
constraints:
nullable: false
primaryKey: true
primaryKeyName: PK_clients
name: id
type: TEXT
- column:
constraints:
nullable: false
name: email
type: TEXT
- column:
constraints:
nullable: false
name: email_is_confirmed
type: BOOLEAN
- createTable: - createTable:
tableName: schedule tableName: schedule
columns: columns:
@@ -7,6 +7,7 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import ru.vyatsu.qr_access_api.database.utils.InsertDatabaseHelper import ru.vyatsu.qr_access_api.database.utils.InsertDatabaseHelper
import ru.vyatsu.qr_access_api.qr.repository.QrRepository
@JdbcTest @JdbcTest
@Import(RepositoryTest.Configuration::class) @Import(RepositoryTest.Configuration::class)