From 6c1b01c4e0c84cc39f8cea60c99e90725094fe2b Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 13:59:25 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE3=E4=BB=B6=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 時刻同期後に fake-hwclock へ保存し、RTC のない Raspberry Pi で 再起動時に時刻が巻き戻るのを防ぐ - 選択商品のバーコード PDF もキャッシュ対象にし、枠線あり版も 起動時にウォームアップ。商品の追加・更新・削除をイベントで受けて キャッシュを作り直す - Danger Zone のパスワード照合に接続元ごとのレート制限を追加。 連続失敗が上限に達している間は照合せず 429 と Retry-After を返す --- api.yaml | 14 +- .../controller/api/DangerZoneApiController.kt | 33 ++++- .../controller/api/ItemApiController.kt | 4 +- .../server/service/BarcodePdfService.kt | 97 ++++++++++++-- .../service/DangerZoneVerifyRateLimiter.kt | 95 ++++++++++++++ .../kidspos/server/service/ItemService.kt | 5 + .../server/service/ItemsChangedEvent.kt | 11 ++ .../server/service/SystemClockUpdater.kt | 45 ++++++- .../advice/GlobalExceptionHandlerTest.kt | 4 - .../api/DangerZoneApiControllerTest.kt | 77 +++++++++++ .../controller/api/ItemApiControllerTest.kt | 8 +- .../api/ItemApiControllerUnitTest.kt | 4 - .../server/service/BarcodePdfServiceTest.kt | 123 +++++++++++++++++- .../service/ConstructorInjectionTest.kt | 4 +- .../DangerZoneVerifyRateLimiterTest.kt | 101 ++++++++++++++ .../kidspos/server/service/ItemServiceTest.kt | 6 +- .../server/service/SystemClockUpdaterTest.kt | 74 ++++++++++- 17 files changed, 668 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt create mode 100644 src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt create mode 100644 src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt diff --git a/api.yaml b/api.yaml index 59e0563..651c295 100644 --- a/api.yaml +++ b/api.yaml @@ -1451,7 +1451,7 @@ paths: tags: - DangerZone summary: Danger Zone パスワード照合 - description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する + description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する。接続元ごとに連続失敗を数え、上限に達している間は429を返す operationId: verifyDangerZonePassword requestBody: required: true @@ -1472,6 +1472,18 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '429': + description: 連続失敗が上限に達したため一時的に照合を受け付けない + headers: + Retry-After: + description: 再試行できるまでの秒数 + schema: + type: integer + example: 60 + content: + application/json: + schema: + $ref: '#/components/schemas/DangerZoneVerifyResponse' components: diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt index e958357..7cb473e 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt @@ -7,9 +7,12 @@ import info.nukoneko.kidspos.server.controller.dto.response.DangerZonePasswordRe import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneStatusResponse import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneVerifyResponse import info.nukoneko.kidspos.server.service.DangerZonePasswordService +import info.nukoneko.kidspos.server.service.DangerZoneVerifyRateLimiter import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "DangerZone", description = "危険操作を保護するパスワードAPI") class DangerZoneApiController( private val dangerZonePasswordService: DangerZonePasswordService, + private val verifyRateLimiter: DangerZoneVerifyRateLimiter, ) { @GetMapping("/status") @Operation(summary = "設定状態取得", description = "Danger Zone パスワードが設定済みかを取得します") @@ -55,11 +59,36 @@ class DangerZoneApiController( @Operation(summary = "パスワード照合", description = "入力されたパスワードがDanger Zone パスワードと一致するかを判定します") fun verify( @Valid @RequestBody request: VerifyDangerZonePasswordRequest, - ): DangerZoneVerifyResponse { + httpRequest: HttpServletRequest, + ): ResponseEntity { + val clientKey = clientKeyOf(httpRequest) + val retryAfter = verifyRateLimiter.retryAfterSeconds(clientKey) + if (retryAfter > 0) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, retryAfter.toString()) + .body( + DangerZoneVerifyResponse( + valid = false, + configured = dangerZonePasswordService.isConfigured(), + message = "試行回数が多すぎます。${retryAfter}秒後にもう一度お試しください", + ), + ) + } + val result = dangerZonePasswordService.verify(request.password) - return DangerZoneVerifyResponse(result.valid, result.configured, result.message) + when { + result.valid -> verifyRateLimiter.recordSuccess(clientKey) + result.configured -> verifyRateLimiter.recordFailure(clientKey) + } + return ResponseEntity.ok(DangerZoneVerifyResponse(result.valid, result.configured, result.message)) } + /** + * イントラネット内で直接受けるため、詐称できるX-Forwarded-Forは見ずに接続元アドレスだけを使う。 + */ + private fun clientKeyOf(request: HttpServletRequest): String = request.remoteAddr ?: "unknown" + private fun toResponse(result: DangerZonePasswordService.ChangeResult): ResponseEntity { val status = if (result.succeeded) HttpStatus.OK else HttpStatus.BAD_REQUEST return ResponseEntity diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt index 132da86..70f1f1a 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt @@ -7,7 +7,6 @@ import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.domain.exception.InvalidBarcodeException import info.nukoneko.kidspos.server.domain.exception.ItemNotFoundException import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -42,7 +41,6 @@ class ItemApiController( private val itemService: ItemService, private val itemMapper: ItemMapper, private val validationService: ValidationService, - private val barcodeService: BarcodeService, private val barcodePdfService: BarcodePdfService, ) { private val logger = LoggerFactory.getLogger(ItemApiController::class.java) @@ -279,7 +277,7 @@ class ItemApiController( throw ItemNotFoundException() } - val pdfBytes = barcodeService.generateBarcodePdf(items, showBorders) + val pdfBytes = barcodePdfService.getSelectedItemsPdf(items, showBorders) logger.info("Selected barcode PDF generated successfully with {} items", items.size) diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt index 7015b33..f05c9b6 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt @@ -1,20 +1,26 @@ package info.nukoneko.kidspos.server.service import info.nukoneko.kidspos.server.entity.ItemEntity +import jakarta.annotation.PreDestroy import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener import org.springframework.stereotype.Service +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener import java.security.MessageDigest +import java.util.Collections import java.util.concurrent.ConcurrentHashMap -import kotlin.concurrent.thread +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** - * 全商品のバーコードPDFを保持するサービス + * バーコードPDFを保持するサービス * * 商品一覧が変わらない限り生成済みのバイト列を返す。 - * 起動直後にバックグラウンドで生成しておくことで、 + * 起動直後と商品変更後にバックグラウンドで生成しておくことで、 * 端末側のリクエストがタイムアウトする事態を避ける。 */ @Service @@ -27,6 +33,20 @@ class BarcodePdfService( private val logger = LoggerFactory.getLogger(BarcodePdfService::class.java) private val cache = ConcurrentHashMap() private val locks = ConcurrentHashMap() + private val selectedLock = Any() + + private val selectedCache: MutableMap = + Collections.synchronizedMap( + object : LinkedHashMap(SELECTED_CACHE_CAPACITY, LOAD_FACTOR, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > SELECTED_CACHE_CAPACITY + }, + ) + + private val warmUpExecutor: ExecutorService by lazy { + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "barcode-pdf-warmup").apply { isDaemon = true } + } + } fun getAllItemsPdf(showBorders: Boolean = false): ByteArray { val items = itemService.findAll() @@ -59,13 +79,49 @@ class BarcodePdfService( } } + /** + * 選択された商品のPDFを返す。 + * + * 署名をそのままキャッシュキーにしているため、商品名や価格が変われば別のキーになり + * 古いエントリはLRUで押し出される。生成を直列化して、同じ選択の同時要求で + * 非力な端末のCPUを何度も使わないようにする。 + */ + fun getSelectedItemsPdf( + items: List, + showBorders: Boolean, + ): ByteArray { + val signature = signatureOf(items, showBorders) + + selectedCache[signature]?.let { cached -> + logger.debug("Reusing cached selected barcode PDF (showBorders={})", showBorders) + return cached + } + + return synchronized(selectedLock) { + selectedCache[signature]?.let { return@synchronized it } + + val startedAt = System.currentTimeMillis() + val bytes = barcodeService.generateBarcodePdf(items, showBorders) + selectedCache[signature] = bytes + logger.info( + "Generated selected barcode PDF for {} items in {} ms (showBorders={})", + items.size, + System.currentTimeMillis() - startedAt, + showBorders, + ) + bytes + } + } + fun isCached(showBorders: Boolean = false): Boolean = cache.containsKey(showBorders) fun warmUp() { - try { - getAllItemsPdf(false) - } catch (e: Exception) { - logger.warn("Failed to warm up barcode PDF cache: {}", e.message) + BORDER_VARIANTS.forEach { showBorders -> + try { + getAllItemsPdf(showBorders) + } catch (e: Exception) { + logger.warn("Failed to warm up barcode PDF cache (showBorders={}): {}", showBorders, e.message) + } } } @@ -75,8 +131,25 @@ class BarcodePdfService( logger.debug("Barcode PDF warm-up on startup is disabled") return } - thread(start = true, isDaemon = true, name = "barcode-pdf-warmup") { - warmUp() + scheduleWarmUp() + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + fun onItemsChanged(event: ItemsChangedEvent) { + logger.debug("Rebuilding barcode PDF cache after item change (itemId={})", event.itemId) + scheduleWarmUp() + } + + @PreDestroy + fun shutdown() { + warmUpExecutor.shutdownNow() + } + + private fun scheduleWarmUp() { + try { + warmUpExecutor.execute { warmUp() } + } catch (e: RejectedExecutionException) { + logger.debug("Barcode PDF warm-up was not scheduled: {}", e.message) } } @@ -96,4 +169,10 @@ class BarcodePdfService( val signature: String, val bytes: ByteArray, ) + + private companion object { + val BORDER_VARIANTS = listOf(false, true) + const val SELECTED_CACHE_CAPACITY = 8 + const val LOAD_FACTOR = 0.75f + } } diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt new file mode 100644 index 0000000..33e0f23 --- /dev/null +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt @@ -0,0 +1,95 @@ +package info.nukoneko.kidspos.server.service + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap + +internal const val DEFAULT_VERIFY_MAX_FAILURES = 5 +internal const val DEFAULT_VERIFY_BLOCK_SECONDS = 60L + +/** + * Danger Zone パスワード照合の連打を抑えるレートリミッター + * + * 照合は PBKDF2 を10万回反復するため、総当たりは推測される危険に加えて + * Raspberry Pi の CPU を占有し他の会計処理まで巻き添えにする。 + * クライアントごとに失敗回数を数え、上限に達した間は照合そのものを行わせない。 + */ +@Component +class DangerZoneVerifyRateLimiter( + @Value("\${app.danger-zone.verify.max-failures:$DEFAULT_VERIFY_MAX_FAILURES}") + private val maxFailures: Int = DEFAULT_VERIFY_MAX_FAILURES, + @Value("\${app.danger-zone.verify.block-seconds:$DEFAULT_VERIFY_BLOCK_SECONDS}") + private val blockSeconds: Long = DEFAULT_VERIFY_BLOCK_SECONDS, +) { + private val logger = LoggerFactory.getLogger(DangerZoneVerifyRateLimiter::class.java) + private val attempts = ConcurrentHashMap() + + @Volatile + internal var timeSource: () -> Long = System::currentTimeMillis + + /** + * ブロック中なら解除までの残り秒数を、そうでなければ0を返す。 + */ + fun retryAfterSeconds(clientKey: String): Long { + val attempt = attempts[clientKey] ?: return 0 + val remaining = attempt.blockedUntil - timeSource() + if (remaining <= 0) { + return 0 + } + return (remaining + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND + } + + fun recordFailure(clientKey: String) { + val now = timeSource() + val blockMillis = blockSeconds * MILLIS_PER_SECOND + purgeExpired(now, blockMillis) + + val updated = + attempts.compute(clientKey) { _, current -> + val continued = current != null && now - current.lastFailureAt <= blockMillis + val failures = if (continued) current.failures + 1 else 1 + if (failures >= maxFailures) { + Attempt(failures = 0, blockedUntil = now + blockMillis, lastFailureAt = now) + } else { + Attempt(failures = failures, blockedUntil = 0, lastFailureAt = now) + } + } + + if (updated != null && updated.blockedUntil > now) { + logger.warn("Danger zone verification blocked for {} seconds (client={})", blockSeconds, clientKey) + } + } + + fun recordSuccess(clientKey: String) { + attempts.remove(clientKey) + } + + internal fun trackedClientCount(): Int = attempts.size + + /** + * 失敗した端末の分だけエントリが残るため、上限を超えたら期限切れの分を捨てる。 + */ + private fun purgeExpired( + now: Long, + blockMillis: Long, + ) { + if (attempts.size < MAX_TRACKED_CLIENTS) { + return + } + attempts.entries.removeIf { (_, attempt) -> + attempt.blockedUntil <= now && now - attempt.lastFailureAt > blockMillis + } + } + + private data class Attempt( + val failures: Int, + val blockedUntil: Long, + val lastFailureAt: Long, + ) + + private companion object { + const val MILLIS_PER_SECOND = 1_000L + const val MAX_TRACKED_CLIENTS = 1_000 + } +} diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt index 24c61c1..5095260 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Caching +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -39,12 +40,14 @@ import org.springframework.transaction.annotation.Transactional * @constructor Creates ItemService with required dependencies * @param repository Repository for item data access * @param idGenerationService Service for generating unique item IDs + * @param eventPublisher Publisher used to notify listeners about item changes */ @Service @Transactional class ItemService( private val repository: ItemRepository, private val idGenerationService: IdGenerationService, + private val eventPublisher: ApplicationEventPublisher, ) { private val logger = LoggerFactory.getLogger(ItemService::class.java) @@ -95,6 +98,7 @@ class ItemService( val item = ItemEntity(generatedId, finalBarcode, itemBean.name, itemBean.price) val savedItem = repository.save(item) logger.info("Item created successfully with ID: {}, barcode: {}", savedItem.id, savedItem.barcode) + eventPublisher.publishEvent(ItemsChangedEvent(savedItem.id)) return savedItem } @@ -110,6 +114,7 @@ class ItemService( if (item != null) { repository.delete(item) logger.info("Item deleted successfully with ID: {}", id) + eventPublisher.publishEvent(ItemsChangedEvent(id)) } } } diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt new file mode 100644 index 0000000..f7883c4 --- /dev/null +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt @@ -0,0 +1,11 @@ +package info.nukoneko.kidspos.server.service + +/** + * 商品マスタが変更されたことを表すイベント + * + * バーコードPDFのキャッシュを作り直すために使う。 + * BarcodePdfService は ItemService に依存しているため、逆向きの直接参照を避けてイベントで通知する。 + */ +class ItemsChangedEvent( + val itemId: Int, +) diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt index e64343e..a2b0909 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt @@ -1,14 +1,30 @@ package info.nukoneko.kidspos.server.service +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import java.io.File import java.util.concurrent.TimeUnit +internal const val DEFAULT_FAKE_HWCLOCK_PATHS = "/sbin/fake-hwclock,/usr/sbin/fake-hwclock,/usr/bin/fake-hwclock" + /** * OSのシステム時刻を変更するコンポーネント */ @Component -class SystemClockUpdater { - fun setTime(epochMillis: Long): Result = runCommand(buildCommand(epochMillis)) +class SystemClockUpdater( + @Value("\${app.system-time.fake-hwclock-paths:$DEFAULT_FAKE_HWCLOCK_PATHS}") + private val fakeHwclockPaths: List, +) { + private val logger = LoggerFactory.getLogger(SystemClockUpdater::class.java) + + fun setTime(epochMillis: Long): Result { + val result = runCommand(buildCommand(epochMillis)) + if (result.succeeded) { + persistToFakeHwclock() + } + return result + } internal fun buildCommand(epochMillis: Long): List { val seconds = epochMillis / MILLIS_PER_SECOND @@ -16,6 +32,31 @@ class SystemClockUpdater { return listOf("date", "-s", "@$seconds.${"%03d".format(millis)}") } + internal fun findFakeHwclock(): String? = + fakeHwclockPaths + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && File(it).canExecute() } + + /** + * Raspberry Pi には RTC がなく、起動直後の時刻は /etc/fake-hwclock.data から復元される。 + * このファイルは毎時の cron と正常シャットダウン時にしか保存されないため、同期した直後に + * 電源を落とすと最大 1 時間巻き戻る。同期のたびに保存して巻き戻りを防ぐ。 + * 保存に失敗してもシステム時刻そのものの変更結果には影響させない。 + */ + internal fun persistToFakeHwclock(): Result { + val executable = + findFakeHwclock() + ?: return Result(succeeded = false, output = "fake-hwclock が見つかりません") + + val result = runCommand(listOf(executable, "save")) + if (result.succeeded) { + logger.info("fake-hwclock に現在時刻を保存しました") + } else { + logger.warn("fake-hwclock の保存に失敗しました: {}", result.output.ifBlank { "詳細不明" }) + } + return result + } + internal fun runCommand(command: List): Result { var process: Process? = null return try { diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt index ba11027..4490946 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt @@ -5,7 +5,6 @@ import info.nukoneko.kidspos.server.controller.api.ItemApiController import info.nukoneko.kidspos.server.controller.dto.request.CreateItemRequest import info.nukoneko.kidspos.server.domain.exception.ItemNotFoundException import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -52,9 +51,6 @@ class GlobalExceptionHandlerTest { @MockBean private lateinit var validationService: ValidationService - @MockBean - private lateinit var barcodeService: BarcodeService - @MockBean private lateinit var barcodePdfService: BarcodePdfService diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt index f75533a..9718c3e 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt @@ -1,6 +1,7 @@ package info.nukoneko.kidspos.server.controller.api import info.nukoneko.kidspos.server.service.DangerZonePasswordService +import info.nukoneko.kidspos.server.service.DangerZoneVerifyRateLimiter import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -17,6 +18,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -30,6 +32,9 @@ class DangerZoneApiControllerTest { @MockBean private lateinit var dangerZonePasswordService: DangerZonePasswordService + @MockBean + private lateinit var verifyRateLimiter: DangerZoneVerifyRateLimiter + @Test fun `未設定なら設定状態はfalseを返す`() { whenever(dangerZonePasswordService.isConfigured()).thenReturn(false) @@ -196,4 +201,76 @@ class DangerZoneApiControllerTest { verify(dangerZonePasswordService, never()).verify(any()) } + + @Test + fun `連打が上限を超えると429を返し照合しない`() { + whenever(verifyRateLimiter.retryAfterSeconds(any())).thenReturn(30) + whenever(dangerZonePasswordService.isConfigured()).thenReturn(true) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"wrong"}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(header().string("Retry-After", "30")) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect(jsonPath("$.configured").value(true)) + .andExpect(jsonPath("$.message").value("試行回数が多すぎます。30秒後にもう一度お試しください")) + + verify(dangerZonePasswordService, never()).verify(any()) + } + + @Test + fun `照合に失敗したら失敗として記録する`() { + whenever(dangerZonePasswordService.verify(eq("wrong"))) + .thenReturn(DangerZonePasswordService.VerifyResult(false, configured = true, message = "パスワードが違います")) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"wrong"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter).recordFailure(any()) + verify(verifyRateLimiter, never()).recordSuccess(any()) + } + + @Test + fun `照合に成功したら記録を消す`() { + whenever(dangerZonePasswordService.verify(eq("kidspos1234"))) + .thenReturn(DangerZonePasswordService.VerifyResult(true, configured = true, message = "認証しました")) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"kidspos1234"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter).recordSuccess(any()) + verify(verifyRateLimiter, never()).recordFailure(any()) + } + + @Test + fun `パスワード未設定の照合は失敗として数えない`() { + whenever(dangerZonePasswordService.verify(eq("kidspos1234"))) + .thenReturn( + DangerZonePasswordService.VerifyResult( + false, + configured = false, + message = "サーバーにパスワードが設定されていません", + ), + ) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"kidspos1234"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter, never()).recordFailure(any()) + } } diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt index 48633d8..65bd632 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt @@ -6,7 +6,6 @@ import info.nukoneko.kidspos.server.controller.dto.request.ItemBean import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.entity.ItemEntity import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -49,9 +48,6 @@ class ItemApiControllerTest { @MockBean private lateinit var validationService: ValidationService - @MockBean - private lateinit var barcodeService: BarcodeService - @MockBean private lateinit var barcodePdfService: BarcodePdfService @@ -372,7 +368,7 @@ class ItemApiControllerTest { @Test fun `should generate selected barcode pdf via get request`() { `when`(itemService.findItem(1)).thenReturn(testItem) - `when`(barcodeService.generateBarcodePdf(listOf(testItem), false)).thenReturn(byteArrayOf(9)) + `when`(barcodePdfService.getSelectedItemsPdf(listOf(testItem), false)).thenReturn(byteArrayOf(9)) mockMvc .perform(get("/api/item/barcode-pdf/selected").param("ids", "1")) @@ -380,7 +376,7 @@ class ItemApiControllerTest { .andExpect(content().contentType(MediaType.APPLICATION_PDF)) .andExpect(header().string("Content-Disposition", "attachment; filename=\"selected_barcodes.pdf\"")) - verify(barcodeService).generateBarcodePdf(listOf(testItem), false) + verify(barcodePdfService).getSelectedItemsPdf(listOf(testItem), false) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt index 8f34298..64a5fa5 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt @@ -5,7 +5,6 @@ import info.nukoneko.kidspos.server.controller.dto.request.ItemBean import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.entity.ItemEntity import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -30,9 +29,6 @@ class ItemApiControllerUnitTest { @Mock private lateinit var validationService: ValidationService - @Mock - private lateinit var barcodeService: BarcodeService - @Mock private lateinit var barcodePdfService: BarcodePdfService diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt index 46b3e4e..4eb2b32 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt @@ -9,6 +9,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock +import org.mockito.Mockito.timeout import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -18,6 +19,7 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @ExtendWith(MockitoExtension::class) @DisplayName("BarcodePdfService") @@ -132,14 +134,19 @@ class BarcodePdfServiceTest { } @Test - @DisplayName("ウォームアップ前後でキャッシュの有無が変わる") + @DisplayName("ウォームアップは罫線ありとなしの両方を作る") fun warmUpFillsCache() { whenever(itemService.findAll()).thenReturn(items) whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) assertFalse(service.isCached(false)) + assertFalse(service.isCached(true)) + service.warmUp() + assertTrue(service.isCached(false)) + assertTrue(service.isCached(true)) } @Test @@ -168,13 +175,123 @@ class BarcodePdfServiceTest { fun warmsUpOnStartupWhenEnabled() { whenever(itemService.findAll()).thenReturn(items) whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) service.warmUpInBackground() + awaitCached(false) + awaitCached(true) + assertTrue(service.isCached(false)) + assertTrue(service.isCached(true)) + } + + @Test + @DisplayName("商品が変わったらキャッシュを作り直す") + fun rebuildsCacheOnItemsChanged() { + val current = AtomicReference(items) + whenever(itemService.findAll()).thenAnswer { current.get() } + whenever(barcodeService.generateBarcodePdf(any(), eq(false))) + .thenReturn(byteArrayOf(1)) + .thenReturn(byteArrayOf(9)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) + + service.warmUp() + assertArrayEquals(byteArrayOf(1), service.getAllItemsPdf(false)) + + current.set(items + ItemEntity(3, "A01000003A", "ぶどう", 200)) + service.onItemsChanged(ItemsChangedEvent(3)) + + verify(barcodeService, timeout(5_000).times(2)).generateBarcodePdf(any(), eq(false)) + assertArrayEquals(byteArrayOf(9), service.getAllItemsPdf(false)) + } + + @Test + @DisplayName("同じ選択の再要求では生成し直さない") + fun reusesCachedSelectedPdf() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(7)) + + val first = service.getSelectedItemsPdf(items, false) + val second = service.getSelectedItemsPdf(items, false) + + assertArrayEquals(byteArrayOf(7), first) + assertArrayEquals(first, second) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("選択の中身が変われば別のPDFを作る") + fun regeneratesSelectedPdfWhenSelectionChanges() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))) + .thenReturn(byteArrayOf(1)) + .thenReturn(byteArrayOf(2)) + + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + assertArrayEquals(byteArrayOf(2), service.getSelectedItemsPdf(listOf(items[0]), false)) + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + + verify(barcodeService, times(2)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("選択PDFの罫線ありとなしは別々に保持される") + fun cachesSelectedPdfPerShowBordersFlag() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) + + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + assertArrayEquals(byteArrayOf(2), service.getSelectedItemsPdf(items, true)) + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(true)) + } + + @Test + @DisplayName("選択PDFのキャッシュは上限を超えると古いものから捨てる") + fun evictsOldestSelectedPdf() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + + val selections = (1..SELECTED_CACHE_CAPACITY + 1).map { listOf(ItemEntity(it, "A0100000${it}A", "商品$it", it * 10)) } + selections.forEach { service.getSelectedItemsPdf(it, false) } + + service.getSelectedItemsPdf(selections.first(), false) + + verify(barcodeService, times(SELECTED_CACHE_CAPACITY + 2)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("同じ選択が同時に要求されても生成は一度だけ行われる") + fun generatesSelectedPdfOnceUnderConcurrentAccess() { + val threadCount = 8 + val start = CountDownLatch(1) + val done = CountDownLatch(threadCount) + + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenAnswer { + Thread.sleep(20) + byteArrayOf(1) + } + + repeat(threadCount) { + Thread { + start.await() + service.getSelectedItemsPdf(items, false) + done.countDown() + }.also { it.isDaemon = true }.start() + } + + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS)) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + } + + private fun awaitCached(showBorders: Boolean) { val deadline = System.currentTimeMillis() + 5_000 - while (!service.isCached(false) && System.currentTimeMillis() < deadline) { + while (!service.isCached(showBorders) && System.currentTimeMillis() < deadline) { Thread.sleep(10) } - assertTrue(service.isCached(false)) + } + + private companion object { + const val SELECTED_CACHE_CAPACITY = 8 } } diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt index e41cf50..36dd683 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt @@ -10,6 +10,7 @@ import org.mockito.Mockito.mock import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationEventPublisher @SpringBootTest class ConstructorInjectionTest { @@ -21,9 +22,10 @@ class ConstructorInjectionTest { // Given - Create mocks val itemRepository = mock(ItemRepository::class.java) val idGenerationService = mock(IdGenerationService::class.java) + val eventPublisher = mock(ApplicationEventPublisher::class.java) // When - Create service with constructor injection - val itemService = ItemService(itemRepository, idGenerationService) + val itemService = ItemService(itemRepository, idGenerationService, eventPublisher) // Then - Service should be properly initialized assertThat(itemService).isNotNull diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt new file mode 100644 index 0000000..7919fbe --- /dev/null +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt @@ -0,0 +1,101 @@ +package info.nukoneko.kidspos.server.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("DangerZoneVerifyRateLimiter") +class DangerZoneVerifyRateLimiterTest { + private lateinit var limiter: DangerZoneVerifyRateLimiter + private var now = 1_700_000_000_000L + + @BeforeEach + fun setUp() { + limiter = DangerZoneVerifyRateLimiter(maxFailures = 3, blockSeconds = 60) + limiter.timeSource = { now } + } + + @Test + fun `失敗が上限未満ならブロックしない`() { + repeat(2) { limiter.recordFailure(CLIENT) } + + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `失敗が上限に達したらブロックする`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `ブロックは時間経過で解除される`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + now += 59_000 + assertEquals(1, limiter.retryAfterSeconds(CLIENT)) + + now += 1_000 + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `解除後は改めて上限まで試行できる`() { + repeat(3) { limiter.recordFailure(CLIENT) } + now += 60_000 + + repeat(2) { limiter.recordFailure(CLIENT) } + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + + limiter.recordFailure(CLIENT) + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `成功すると失敗回数が消える`() { + repeat(2) { limiter.recordFailure(CLIENT) } + limiter.recordSuccess(CLIENT) + + repeat(2) { limiter.recordFailure(CLIENT) } + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `間隔が空いた失敗は数え直す`() { + repeat(2) { limiter.recordFailure(CLIENT) } + + now += 60_001 + limiter.recordFailure(CLIENT) + + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `クライアントごとに別々に数える`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + assertEquals(0, limiter.retryAfterSeconds("192.168.0.99")) + } + + @Test + fun `未知のクライアントはブロックされない`() { + assertEquals(0, limiter.retryAfterSeconds("192.168.0.50")) + } + + @Test + fun `古いエントリは掃除される`() { + repeat(1_000) { index -> limiter.recordFailure("10.0.0.$index") } + now += 60_001 + + limiter.recordFailure(CLIENT) + + assertEquals(1, limiter.trackedClientCount()) + } + + private companion object { + const val CLIENT = "192.168.0.10" + } +} diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt index 619eb73..3a21ede 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito.* import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher import java.util.* @SpringBootTest @@ -20,11 +21,14 @@ class ItemServiceTest { @MockBean private lateinit var idGenerationService: IdGenerationService + private lateinit var eventPublisher: ApplicationEventPublisher + private lateinit var itemService: ItemService @BeforeEach fun setup() { - itemService = ItemService(itemRepository, idGenerationService) + eventPublisher = mock(ApplicationEventPublisher::class.java) + itemService = ItemService(itemRepository, idGenerationService, eventPublisher) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt index 2e11f12..3bdad9f 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt @@ -2,11 +2,14 @@ package info.nukoneko.kidspos.server.service import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File class SystemClockUpdaterTest { - private val updater = SystemClockUpdater() + private val updater = SystemClockUpdater(DEFAULT_FAKE_HWCLOCK_PATHS.split(",")) @Test fun `エポックミリ秒を秒とミリ秒に分けたdateコマンドを組み立てる`() { @@ -41,4 +44,73 @@ class SystemClockUpdaterTest { assertFalse(result.succeeded) assertTrue(result.output.isNotBlank()) } + + @Test + fun `実行可能なfake-hwclockが無ければnullを返す`( + @TempDir tempDir: File, + ) { + val missing = File(tempDir, "fake-hwclock") + val updaterWithoutHwclock = SystemClockUpdater(listOf(missing.absolutePath, " ")) + + assertNull(updaterWithoutHwclock.findFakeHwclock()) + } + + @Test + fun `候補のうち実行可能な最初のパスを選ぶ`( + @TempDir tempDir: File, + ) { + val missing = File(tempDir, "missing") + val executable = writeExecutable(tempDir, "fake-hwclock", "exit 0") + + val updaterWithHwclock = SystemClockUpdater(listOf(missing.absolutePath, executable.absolutePath)) + + assertEquals(executable.absolutePath, updaterWithHwclock.findFakeHwclock()) + } + + @Test + fun `fake-hwclockが無い場合は保存に失敗した結果を返す`( + @TempDir tempDir: File, + ) { + val updaterWithoutHwclock = SystemClockUpdater(listOf(File(tempDir, "missing").absolutePath)) + + val result = updaterWithoutHwclock.persistToFakeHwclock() + + assertFalse(result.succeeded) + assertTrue(result.output.contains("fake-hwclock")) + } + + @Test + fun `fake-hwclockにsaveを渡して実行する`( + @TempDir tempDir: File, + ) { + val marker = File(tempDir, "marker") + val executable = writeExecutable(tempDir, "fake-hwclock", "echo \"\$1\" > ${marker.absolutePath}") + + val result = SystemClockUpdater(listOf(executable.absolutePath)).persistToFakeHwclock() + + assertTrue(result.succeeded) + assertEquals("save", marker.readText().trim()) + } + + @Test + fun `fake-hwclockの保存に失敗しても失敗理由を返す`( + @TempDir tempDir: File, + ) { + val executable = writeExecutable(tempDir, "fake-hwclock", "echo 'permission denied' >&2; exit 1") + + val result = SystemClockUpdater(listOf(executable.absolutePath)).persistToFakeHwclock() + + assertFalse(result.succeeded) + assertEquals("permission denied", result.output) + } + + private fun writeExecutable( + directory: File, + name: String, + body: String, + ): File = + File(directory, name).apply { + writeText("#!/bin/sh\n$body\n") + setExecutable(true) + } } From fe0072a3170fb3487c0185df1a6e984feeaec7d6 Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 15:10:54 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=99=82=E3=81=AB=E6=9C=80=E6=96=B0=20APK=20?= =?UTF-8?q?=E3=82=82=E7=99=BB=E9=8C=B2=E3=81=99=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-app.sh の最後で upload-apk.sh を呼び、jar が同一バージョンで 早期終了する場合も APK の確認だけは行う。 APK の登録に失敗してもサーバーの更新は成功扱いにし、警告のみ出す。 オフライン更新(jar のパス指定)と --skip-apk 指定時は確認しない。 --- scripts/raspberry-pi/README.md | 17 ++- scripts/raspberry-pi/test-update-app.sh | 132 ++++++++++++++++++++++++ scripts/raspberry-pi/update-app.sh | 27 +++++ 3 files changed, 171 insertions(+), 5 deletions(-) diff --git a/scripts/raspberry-pi/README.md b/scripts/raspberry-pi/README.md index fc85e22..2b8c0e1 100644 --- a/scripts/raspberry-pi/README.md +++ b/scripts/raspberry-pi/README.md @@ -15,7 +15,7 @@ Raspberry Pi 上で KidsPOS Server を運用するためのファイル一式で | ファイル | 用途 | |---|---| | install.sh | 初回セットアップ(ディレクトリ作成、systemd ユニット配置、自動起動有効化、jar 導入) | -| update-app.sh | jar の更新(バックアップ、差し替え、ヘルスチェック、失敗時の巻き戻し) | +| update-app.sh | jar の更新(バックアップ、差し替え、ヘルスチェック、失敗時の巻き戻し)と最新 APK の登録 | | upload-apk.sh | Android アプリの APK をサーバーへ登録(GitHub Releases から取得、または持ち込んだファイル) | | doctor.sh | 稼働診断(Java、jar、サービス、ヘルスチェック、DB、ディスク、ログ、時刻) | | kidspos-server.service | systemd ユニットのテンプレート | @@ -24,7 +24,7 @@ Raspberry Pi 上で KidsPOS Server を運用するためのファイル一式で ## 初回セットアップ このディレクトリを Raspberry Pi にコピーし、install.sh を実行します。 -必要なコマンドの確認、ディレクトリ作成、update-app.sh と doctor.sh の配置、systemd ユニットの配置と自動起動の有効化、jar の導入、最後に診断まで一度に行います。 +必要なコマンドの確認、ディレクトリ作成、update-app.sh と doctor.sh と upload-apk.sh の配置、systemd ユニットの配置と自動起動の有効化、jar の導入、最後に診断まで一度に行います。 ```bash sudo ./install.sh # GitHub Releases から最新の app.jar を導入(要インターネット接続) @@ -57,7 +57,12 @@ sudo /opt/kidspos/update-app.sh ``` 最新リリースを確認し、未適用なら「停止 → DB と旧 jar のバックアップ → 差し替え → 起動 → ヘルスチェック」を自動で行います。 -同一バージョンなら何もしません(--force で強制再インストール)。 +同一バージョンなら jar の差し替えは行いません(--force で強制再インストール)。 + +続けて Android アプリの最新 APK も確認し、新しいものがあればサーバーへ登録します。 +jar が同一バージョンで差し替えを行わなかった場合も APK の確認だけは行います。 +APK の登録に失敗しても(ネット不通、リリースが無いなど)警告のみでサーバーの更新は成功扱いになります。 +APK を確認せずサーバーだけ更新したい場合は --skip-apk を付けてください。 ### オフライン時(jar を持ち込む場合) @@ -76,7 +81,9 @@ sudo /opt/kidspos/update-app.sh /path/to/app.jar ## Android アプリ(APK)の登録 タブレットのアプリ更新はサーバーに登録された APK を配信する仕組みです。 -ブラウザの /apk 画面からアップロードするほか、コマンドからも登録できます。 +ネット接続時の update-app.sh は最後にこのスクリプトを呼ぶため、通常はサーバーの更新だけで APK も最新になります。 +個別に登録したい場合や、オフラインで APK を持ち込む場合は以下のように単体で実行します。 +ブラウザの /apk 画面からアップロードすることもできます。 バージョン名とバージョンコードは APK から自動で読み取られるため、指定は不要です。 ### ネット接続時 @@ -151,7 +158,7 @@ Flyway は前進専用のため、DB を戻さずに jar だけ旧バージョ | KIDSPOS_HEALTH_URL | http://localhost:8080/api/status | install / update / doctor | | KIDSPOS_REPO | KidsPOSProject/KidsPOS-Server | update / doctor | | KIDSPOS_ANDROID_REPO | KidsPOSProject/KidsPOS-for-Android | upload-apk | -| KIDSPOS_SERVER_URL | http://localhost:8080 | upload-apk | +| KIDSPOS_SERVER_URL | http://localhost:8080 | update / upload-apk | | KIDSPOS_UPLOAD_TIMEOUT | 600 | upload-apk | | KIDSPOS_REQUIRED_JAVA_MAJOR | 21 | install / doctor | | KIDSPOS_HEALTH_RETRIES | update は 600、install は 300 | install / update | diff --git a/scripts/raspberry-pi/test-update-app.sh b/scripts/raspberry-pi/test-update-app.sh index fd43def..259a88a 100755 --- a/scripts/raspberry-pi/test-update-app.sh +++ b/scripts/raspberry-pi/test-update-app.sh @@ -53,6 +53,8 @@ setup() { export CALL_LOG="${WORK}/calls.log" export HEALTH_OK_FILE="${WORK}/health-ok" export FAIL_START_ONCE_FILE="${WORK}/fail-start-once" + export RELEASE_JSON_FILE="${WORK}/release.json" + export UPLOAD_APK_EXIT=0 : > "$CALL_LOG" cat > "${STUB_DIR}/sudo" <<'EOF' @@ -73,6 +75,23 @@ EOF cat > "${STUB_DIR}/curl" <<'EOF' #!/usr/bin/env bash echo "curl $*" >> "$CALL_LOG" +URL="${@: -1}" +case "$URL" in + https://api.github.com/*) + cat "$RELEASE_JSON_FILE" + exit 0 + ;; + https://example.invalid/*) + OUT="" + PREV="" + for arg in "$@"; do + [ "$PREV" = "-o" ] && OUT="$arg" + PREV="$arg" + done + printf 'PK-downloaded-jar' > "$OUT" + exit 0 + ;; +esac if [ -f "$HEALTH_OK_FILE" ]; then exit 0 fi @@ -95,6 +114,20 @@ teardown() { rm -rf "$WORK" } +install_upload_apk_stub() { + cat > "${APP_DIR}/upload-apk.sh" <<'EOF' +#!/usr/bin/env bash +echo "upload-apk $*" >> "$CALL_LOG" +exit "${UPLOAD_APK_EXIT:-0}" +EOF + chmod +x "${APP_DIR}/upload-apk.sh" +} + +write_release_json() { + printf '[{"tag_name":"%s","draft":false,"prerelease":false,"assets":[{"name":"app.jar","browser_download_url":"https://example.invalid/app.jar"}]}]' \ + "$1" > "$RELEASE_JSON_FILE" +} + run_update() { set +e env PATH="${STUB_DIR}:${PATH}" \ @@ -255,6 +288,98 @@ test_non_jar_file_is_rejected() { teardown } +test_apk_sync_after_online_update() { + setup "オンライン更新の成功後に APK の確認が行われる" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + install_upload_apk_stub + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_eq "PK-downloaded-jar" "$(cat "${APP_DIR}/app.jar")" "取得した jar が配置される" + assert_contains "$CALL_LOG" "upload-apk --server http://localhost:8080" "APK 登録スクリプトが呼ばれる" + teardown +} + +test_apk_sync_when_already_up_to_date() { + setup "jar が最新でも APK の確認は行われる" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + install_upload_apk_stub + echo "v9.9.9" > "${APP_DIR}/.installed-version" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "すでに最新です" "最新である旨が出力される" + assert_contains "$CALL_LOG" "upload-apk --server http://localhost:8080" "APK 登録スクリプトが呼ばれる" + assert_not_contains "$CALL_LOG" "systemctl" "サービスは操作されない" + assert_eq "PK-old-jar" "$(cat "${APP_DIR}/app.jar")" "jar は差し替えられない" + teardown +} + +test_apk_sync_failure_does_not_fail_update() { + setup "APK の登録に失敗してもサーバーの更新は成功扱いになる" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + install_upload_apk_stub + export UPLOAD_APK_EXIT=1 + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "APK の更新に失敗しました" "警告が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了扱いになる" + teardown +} + +test_apk_sync_skipped_with_flag() { + setup "--skip-apk を付けると APK の確認は行われない" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + install_upload_apk_stub + run_update --skip-apk + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了する" + assert_not_contains "$CALL_LOG" "upload-apk" "APK 登録スクリプトは呼ばれない" + teardown +} + +test_apk_sync_skipped_for_local_jar() { + setup "持ち込んだ jar での更新では APK の確認は行われない" + touch "$HEALTH_OK_FILE" + install_upload_apk_stub + run_update "${WORK}/new.jar" + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "オフライン更新のため APK の確認は行いません" "スキップした旨が出力される" + assert_not_contains "$CALL_LOG" "upload-apk" "APK 登録スクリプトは呼ばれない" + teardown +} + +test_apk_sync_skipped_when_script_missing() { + setup "APK 登録スクリプトが無くても更新は成功する" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "APK 登録スクリプトが無いため" "スキップした旨が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了する" + teardown +} + +test_apk_sync_not_run_when_rolled_back() { + setup "巻き戻しが起きたときは APK の確認は行われない" + write_release_json "v9.9.9" + install_upload_apk_stub + run_update + + assert_eq 1 "$RC" "終了コードが 1" + assert_contains "${WORK}/out.log" "巻き戻しを実行します" "巻き戻しログが出力される" + assert_not_contains "$CALL_LOG" "upload-apk" "APK 登録スクリプトは呼ばれない" + teardown +} + test_success_path test_health_check_failure_rolls_back test_start_failure_rolls_back @@ -264,6 +389,13 @@ test_health_check_uses_timeout test_health_check_timeout_applies_while_retrying test_unknown_flag_is_rejected test_non_jar_file_is_rejected +test_apk_sync_after_online_update +test_apk_sync_when_already_up_to_date +test_apk_sync_failure_does_not_fail_update +test_apk_sync_skipped_with_flag +test_apk_sync_skipped_for_local_jar +test_apk_sync_skipped_when_script_missing +test_apk_sync_not_run_when_rolled_back echo "" echo "passed: $PASS_COUNT, failed: $FAIL_COUNT" diff --git a/scripts/raspberry-pi/update-app.sh b/scripts/raspberry-pi/update-app.sh index 3fa7cd1..79eb61f 100755 --- a/scripts/raspberry-pi/update-app.sh +++ b/scripts/raspberry-pi/update-app.sh @@ -9,18 +9,21 @@ HEALTH_URL="${KIDSPOS_HEALTH_URL:-http://localhost:8080/api/status}" HEALTH_RETRIES="${KIDSPOS_HEALTH_RETRIES:-600}" HEALTH_TIMEOUT="${KIDSPOS_HEALTH_TIMEOUT:-10}" BACKUP_KEEP="${KIDSPOS_BACKUP_KEEP:-5}" +SERVER_URL="${KIDSPOS_SERVER_URL:-http://localhost:8080}" ASSET_NAME="app.jar" JAR_PATH="${APP_DIR}/${JAR_NAME}" DB_PATH="${APP_DIR}/kidspos.db" BACKUP_DIR="${APP_DIR}/backup" VERSION_FILE="${APP_DIR}/.installed-version" +UPLOAD_SCRIPT="${APP_DIR}/upload-apk.sh" usage() { echo "Usage:" echo " sudo $0 GitHub Releases から最新の ${ASSET_NAME} を取得して更新(要インターネット接続)" echo " sudo $0 手元に持ち込んだ jar ファイルで更新(オフライン運用)" echo " sudo $0 --force 同一バージョンでも強制的に再インストール" + echo " sudo $0 --skip-apk サーバーの更新のみ行い、APK の確認は行わない" exit 1 } @@ -28,10 +31,12 @@ log() { echo "[update-app] $*"; } fail() { echo "[update-app] ERROR: $*" >&2; exit 1; } FORCE=false +SKIP_APK=false LOCAL_JAR="" for arg in "$@"; do case "$arg" in --force) FORCE=true ;; + --skip-apk) SKIP_APK=true ;; -h|--help) usage ;; -*) echo "[update-app] 不明なオプション: $arg" >&2 @@ -41,6 +46,25 @@ for arg in "$@"; do esac done +# APK の登録に失敗してもサーバーの更新は完了しているため、警告のみで成功扱いにする +sync_apk() { + if [ "$SKIP_APK" = true ]; then + return 0 + fi + if [ -n "$LOCAL_JAR" ]; then + log "オフライン更新のため APK の確認は行いません(登録する場合: ${UPLOAD_SCRIPT} )" + return 0 + fi + if [ ! -x "$UPLOAD_SCRIPT" ]; then + log "APK 登録スクリプトが無いため APK の確認は行いません: $UPLOAD_SCRIPT" + return 0 + fi + log "最新の APK を確認します" + if ! "$UPLOAD_SCRIPT" --server "$SERVER_URL"; then + log "WARN: APK の更新に失敗しました。サーバーの更新は完了しています" + fi +} + [ -d "$APP_DIR" ] || fail "アプリケーションディレクトリがありません: $APP_DIR" command -v curl >/dev/null || fail "curl が必要です" @@ -76,6 +100,7 @@ sys.exit(1) CURRENT_VERSION=$(cat "$VERSION_FILE" 2>/dev/null || echo "none") if [ "$NEW_VERSION" = "$CURRENT_VERSION" ] && [ "$FORCE" = false ]; then log "すでに最新です($CURRENT_VERSION)。強制更新は --force を付けてください。" + sync_apk exit 0 fi @@ -149,3 +174,5 @@ for prefix in "kidspos.db." "${JAR_NAME}."; do done log "更新が完了しました: $NEW_VERSION" + +sync_apk From 900902dea17951f10abe8d4e73a4914fa9872733 Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 16:33:04 +0000 Subject: [PATCH 3/6] =?UTF-8?q?main=20=E3=81=AE=E6=9B=B4=E6=96=B0=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AB=20app.jar=20=E3=82=92=E9=85=8D=E5=B8=83?= =?UTF-8?q?=E3=81=97=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E6=9B=B4=E6=96=B0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main への push で app.jar と kidspos-scripts.tar.gz を添付した server-v* リリースを自動作成する Release Server ワークフローを追加 - app.jar の配布元を一本化するため generate-sdk.yml からは app.jar を外す - update-app.sh がリリース同梱のスクリプトで自身と doctor.sh、upload-apk.sh を差し替えるようにする(--skip-self-update で無効化可能) - 自己更新のテスト 11 件を追加(計 104 assertions) - releases/latest/download/app.jar が使えなくなるためオフライン手順を releases API 経由に変更 --- .github/workflows/generate-sdk.yml | 16 +- .github/workflows/release-server.yml | 100 ++++++++++ docs/raspberry-pi-deployment.md | 5 +- scripts/raspberry-pi/README.md | 15 +- scripts/raspberry-pi/test-update-app.sh | 232 ++++++++++++++++++++++++ scripts/raspberry-pi/update-app.sh | 99 ++++++++-- 6 files changed, 437 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/release-server.yml diff --git a/.github/workflows/generate-sdk.yml b/.github/workflows/generate-sdk.yml index dc67026..06ae159 100644 --- a/.github/workflows/generate-sdk.yml +++ b/.github/workflows/generate-sdk.yml @@ -84,13 +84,7 @@ jobs: path: ${{ env.SDK_ARTIFACT_ID }}-kotlin-${{ steps.version.outputs.SDK_VERSION }}.zip retention-days: 90 - - name: Build application jar - run: | - ./gradlew bootJar -x test -x detekt - JAR_FILE=$(ls build/libs/server-*.jar | grep -v plain | head -1) - cp "$JAR_FILE" app.jar - - - name: Create GitHub Release with SDK and app.jar assets + - name: Create GitHub Release with SDK asset id: release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -105,7 +99,7 @@ jobs: - apiVersion: ${API_VERSION} - 生成元コミット: ${GITHUB_SHA} - ジェネレーター: OpenAPI Generator ${OPENAPI_GENERATOR_VERSION} (kotlin / jvm-okhttp4) - - 同梱: app.jar(サーバー実行可能 jar。java -jar app.jar で起動) + - サーバー実行可能 jar は Release Server ワークフローの server-v* リリースで配布しています EOF ) RELEASE_JSON=$(jq -n \ @@ -128,12 +122,6 @@ jobs: "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${ASSET_NAME}" | jq -r '.id') echo "Uploaded asset ID: $ASSET_ID" echo "ASSET_ID=$ASSET_ID" >> "$GITHUB_OUTPUT" - APP_JAR_ASSET_ID=$(curl -sSf -X POST \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "Content-Type: application/java-archive" \ - --data-binary "@app.jar" \ - "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=app.jar" | jq -r '.id') - echo "Uploaded app.jar asset ID: $APP_JAR_ASSET_ID" create-client-pr: name: Create PR on client repository diff --git a/.github/workflows/release-server.yml b/.github/workflows/release-server.yml new file mode 100644 index 0000000..4f7446f --- /dev/null +++ b/.github/workflows/release-server.yml @@ -0,0 +1,100 @@ +name: Release Server + +# main が更新されるたびにサーバー実行可能 jar(app.jar)と +# Raspberry Pi 用スクリプト一式をリリースとして公開する。 +# Raspberry Pi の update-app.sh はこのリリースを参照して自身とサーバーを更新する。 + +on: + push: + branches: [ main ] + + workflow_dispatch: + +concurrency: + group: release-server + cancel-in-progress: true + +jobs: + release-server: + name: Build and publish app.jar + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up JDK 21 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Determine release version + id: version + run: | + VERSION=$(./gradlew properties -q | grep "^version:" | awk '{print $2}') + RELEASE_VERSION="${VERSION}.${GITHUB_RUN_NUMBER}" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing server version: $RELEASE_VERSION" + + - name: Build application jar + run: | + ./gradlew bootJar -x test -x detekt + JAR_FILE=$(ls build/libs/server-*.jar | grep -v plain | head -1) + [ -n "$JAR_FILE" ] || { echo "bootJar の出力が見つかりません"; ls -la build/libs; exit 1; } + cp "$JAR_FILE" app.jar + + - name: Archive Raspberry Pi scripts + run: | + tar -czf kidspos-scripts.tar.gz -C scripts raspberry-pi raspberry-pi-display + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.RELEASE_VERSION }} + run: | + BODY=$(cat < "$CALL_LOG" @@ -81,6 +83,17 @@ case "$URL" in cat "$RELEASE_JSON_FILE" exit 0 ;; + https://example.invalid/kidspos-scripts.tar.gz) + [ "${SCRIPTS_DOWNLOAD_EXIT:-0}" = "0" ] || exit "${SCRIPTS_DOWNLOAD_EXIT}" + OUT="" + PREV="" + for arg in "$@"; do + [ "$PREV" = "-o" ] && OUT="$arg" + PREV="$arg" + done + cp "$SCRIPTS_TARBALL_FILE" "$OUT" + exit 0 + ;; https://example.invalid/*) OUT="" PREV="" @@ -128,6 +141,40 @@ write_release_json() { "$1" > "$RELEASE_JSON_FILE" } +write_release_json_with_scripts() { + printf '[{"tag_name":"%s","draft":false,"prerelease":false,"assets":[{"name":"app.jar","browser_download_url":"https://example.invalid/app.jar"},{"name":"kidspos-scripts.tar.gz","browser_download_url":"https://example.invalid/kidspos-scripts.tar.gz"}]}]' \ + "$1" > "$RELEASE_JSON_FILE" +} + +make_scripts_tarball() { + local names=("$@") + [ "${#names[@]}" -gt 0 ] || names=(update-app.sh doctor.sh upload-apk.sh) + local src="${WORK}/scripts-src/raspberry-pi" + rm -rf "${WORK}/scripts-src" + mkdir -p "$src" + local name + for name in "${names[@]}"; do + if [ "$name" = "upload-apk.sh" ]; then + cat > "${src}/${name}" <<'EOF' +#!/usr/bin/env bash +echo "upload-apk-new $*" >> "$CALL_LOG" +exit 0 +EOF + else + printf '#!/usr/bin/env bash\n# new-script-marker\n' > "${src}/${name}" + fi + done + tar -czf "$SCRIPTS_TARBALL_FILE" -C "${WORK}/scripts-src" raspberry-pi +} + +assert_scripts_untouched() { + if [ -e "${APP_DIR}/update-app.sh" ] || [ -e "${APP_DIR}/doctor.sh" ]; then + fail_assert "$1" + else + pass "$1" + fi +} + run_update() { set +e env PATH="${STUB_DIR}:${PATH}" \ @@ -380,6 +427,180 @@ test_apk_sync_not_run_when_rolled_back() { teardown } +test_self_update_replaces_scripts() { + setup "リリースに同梱されたスクリプトで自身が差し替えられる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + install_upload_apk_stub + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${APP_DIR}/update-app.sh" "new-script-marker" "update-app.sh が差し替わる" + assert_contains "${APP_DIR}/doctor.sh" "new-script-marker" "doctor.sh が差し替わる" + assert_contains "${APP_DIR}/upload-apk.sh" "upload-apk-new" "upload-apk.sh が差し替わる" + assert_eq "v9.9.9" "$(cat "${APP_DIR}/.installed-scripts-version")" "スクリプトのバージョンが記録される" + if [ -x "${APP_DIR}/update-app.sh" ]; then + pass "差し替えたスクリプトに実行権限が付く" + else + fail_assert "差し替えたスクリプトに実行権限が付く" + fi + if [ -e "${APP_DIR}/.scripts-update" ]; then + fail_assert "作業ディレクトリが後片付けされる" + else + pass "作業ディレクトリが後片付けされる" + fi + teardown +} + +test_self_update_runs_before_apk_sync() { + setup "APK の確認は差し替え後の upload-apk.sh で行われる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + install_upload_apk_stub + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "$CALL_LOG" "upload-apk-new --server http://localhost:8080" "新しい APK 登録スクリプトが呼ばれる" + teardown +} + +test_self_update_when_jar_already_latest() { + setup "jar が最新でもスクリプトが古ければ差し替えられる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + install_upload_apk_stub + echo "v9.9.9" > "${APP_DIR}/.installed-version" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "すでに最新です" "最新である旨が出力される" + assert_contains "${APP_DIR}/doctor.sh" "new-script-marker" "doctor.sh が差し替わる" + assert_not_contains "$CALL_LOG" "systemctl" "サービスは操作されない" + teardown +} + +test_self_update_skipped_when_scripts_version_matches() { + setup "スクリプトが同じバージョンなら再取得しない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + install_upload_apk_stub + echo "v9.9.9" > "${APP_DIR}/.installed-version" + echo "v9.9.9" > "${APP_DIR}/.installed-scripts-version" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_not_contains "$CALL_LOG" "kidspos-scripts.tar.gz" "スクリプトはダウンロードされない" + assert_contains "$CALL_LOG" "upload-apk --server http://localhost:8080" "既存の APK 登録スクリプトが呼ばれる" + teardown +} + +test_self_update_skipped_with_flag() { + setup "--skip-self-update を付けるとスクリプトは差し替えられない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update --skip-self-update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了する" + assert_not_contains "$CALL_LOG" "kidspos-scripts.tar.gz" "スクリプトはダウンロードされない" + assert_scripts_untouched "スクリプトは配置されない" + teardown +} + +test_self_update_skipped_when_asset_missing() { + setup "リリースにスクリプトが無くても更新は成功する" + touch "$HEALTH_OK_FILE" + write_release_json "v9.9.9" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "kidspos-scripts.tar.gz が無いためスクリプトの更新は行いません" "スキップした旨が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了する" + assert_scripts_untouched "スクリプトは配置されない" + teardown +} + +test_self_update_download_failure_does_not_fail_update() { + setup "スクリプトの取得に失敗してもサーバーの更新は成功扱いになる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + export SCRIPTS_DOWNLOAD_EXIT=22 + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "スクリプトの取得に失敗しました" "警告が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了扱いになる" + assert_scripts_untouched "スクリプトは配置されない" + if [ -e "${APP_DIR}/.installed-scripts-version" ]; then + fail_assert "スクリプトのバージョンは記録されない" + else + pass "スクリプトのバージョンは記録されない" + fi + teardown +} + +test_self_update_extract_failure_does_not_fail_update() { + setup "スクリプトの展開に失敗してもサーバーの更新は成功扱いになる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + printf 'not-a-tarball' > "$SCRIPTS_TARBALL_FILE" + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "スクリプトの展開に失敗しました" "警告が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了扱いになる" + assert_scripts_untouched "スクリプトは配置されない" + teardown +} + +test_self_update_partial_tarball_is_not_recorded() { + setup "配布物に足りないスクリプトがあればバージョンを記録しない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball update-app.sh upload-apk.sh + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "配布物に含まれていません: doctor.sh" "不足が警告される" + assert_contains "${APP_DIR}/update-app.sh" "new-script-marker" "含まれている分は差し替わる" + if [ -e "${APP_DIR}/.installed-scripts-version" ]; then + fail_assert "スクリプトのバージョンは記録されない" + else + pass "スクリプトのバージョンは記録されない" + fi + teardown +} + +test_self_update_skipped_for_local_jar() { + setup "持ち込んだ jar での更新ではスクリプトは差し替えられない" + touch "$HEALTH_OK_FILE" + run_update "${WORK}/new.jar" + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了する" + assert_scripts_untouched "スクリプトは配置されない" + teardown +} + +test_self_update_not_run_when_rolled_back() { + setup "巻き戻しが起きたときはスクリプトは差し替えられない" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update + + assert_eq 1 "$RC" "終了コードが 1" + assert_contains "${WORK}/out.log" "巻き戻しを実行します" "巻き戻しログが出力される" + assert_not_contains "$CALL_LOG" "kidspos-scripts.tar.gz" "スクリプトはダウンロードされない" + assert_scripts_untouched "スクリプトは配置されない" + teardown +} + test_success_path test_health_check_failure_rolls_back test_start_failure_rolls_back @@ -396,6 +617,17 @@ test_apk_sync_skipped_with_flag test_apk_sync_skipped_for_local_jar test_apk_sync_skipped_when_script_missing test_apk_sync_not_run_when_rolled_back +test_self_update_replaces_scripts +test_self_update_runs_before_apk_sync +test_self_update_when_jar_already_latest +test_self_update_skipped_when_scripts_version_matches +test_self_update_skipped_with_flag +test_self_update_skipped_when_asset_missing +test_self_update_download_failure_does_not_fail_update +test_self_update_extract_failure_does_not_fail_update +test_self_update_partial_tarball_is_not_recorded +test_self_update_skipped_for_local_jar +test_self_update_not_run_when_rolled_back echo "" echo "passed: $PASS_COUNT, failed: $FAIL_COUNT" diff --git a/scripts/raspberry-pi/update-app.sh b/scripts/raspberry-pi/update-app.sh index 79eb61f..2be675f 100755 --- a/scripts/raspberry-pi/update-app.sh +++ b/scripts/raspberry-pi/update-app.sh @@ -11,19 +11,24 @@ HEALTH_TIMEOUT="${KIDSPOS_HEALTH_TIMEOUT:-10}" BACKUP_KEEP="${KIDSPOS_BACKUP_KEEP:-5}" SERVER_URL="${KIDSPOS_SERVER_URL:-http://localhost:8080}" ASSET_NAME="app.jar" +SCRIPTS_ASSET="kidspos-scripts.tar.gz" +MANAGED_SCRIPTS="update-app.sh doctor.sh upload-apk.sh" JAR_PATH="${APP_DIR}/${JAR_NAME}" DB_PATH="${APP_DIR}/kidspos.db" BACKUP_DIR="${APP_DIR}/backup" VERSION_FILE="${APP_DIR}/.installed-version" +SCRIPTS_VERSION_FILE="${APP_DIR}/.installed-scripts-version" +STAGE_DIR="${APP_DIR}/.scripts-update" UPLOAD_SCRIPT="${APP_DIR}/upload-apk.sh" usage() { echo "Usage:" - echo " sudo $0 GitHub Releases から最新の ${ASSET_NAME} を取得して更新(要インターネット接続)" - echo " sudo $0 手元に持ち込んだ jar ファイルで更新(オフライン運用)" - echo " sudo $0 --force 同一バージョンでも強制的に再インストール" - echo " sudo $0 --skip-apk サーバーの更新のみ行い、APK の確認は行わない" + echo " sudo $0 GitHub Releases から最新の ${ASSET_NAME} を取得して更新(要インターネット接続)" + echo " sudo $0 手元に持ち込んだ jar ファイルで更新(オフライン運用)" + echo " sudo $0 --force 同一バージョンでも強制的に再インストール" + echo " sudo $0 --skip-apk サーバーの更新のみ行い、APK の確認は行わない" + echo " sudo $0 --skip-self-update スクリプト自身の更新は行わない" exit 1 } @@ -32,11 +37,14 @@ fail() { echo "[update-app] ERROR: $*" >&2; exit 1; } FORCE=false SKIP_APK=false +SKIP_SELF_UPDATE=false LOCAL_JAR="" +SCRIPTS_URL="" for arg in "$@"; do case "$arg" in --force) FORCE=true ;; --skip-apk) SKIP_APK=true ;; + --skip-self-update) SKIP_SELF_UPDATE=true ;; -h|--help) usage ;; -*) echo "[update-app] 不明なオプション: $arg" >&2 @@ -46,6 +54,68 @@ for arg in "$@"; do esac done +# スクリプトの更新に失敗してもサーバーの更新は完了しているため、警告のみで成功扱いにする。 +# 成功した分だけ差し替え、全て成功したときだけバージョンを記録して次回の再試行に備える +self_update() { + if [ "$SKIP_SELF_UPDATE" = true ]; then + return 0 + fi + if [ -n "$LOCAL_JAR" ]; then + return 0 + fi + if [ -z "$SCRIPTS_URL" ]; then + log "リリースに ${SCRIPTS_ASSET} が無いためスクリプトの更新は行いません" + return 0 + fi + local current + current=$(cat "$SCRIPTS_VERSION_FILE" 2>/dev/null || echo "none") + if [ "$current" = "$NEW_VERSION" ] && [ "$FORCE" = false ]; then + return 0 + fi + + log "スクリプトを更新します: $NEW_VERSION" + rm -rf "$STAGE_DIR" + if ! mkdir -p "$STAGE_DIR"; then + log "WARN: スクリプト更新用ディレクトリを作成できません: $STAGE_DIR" + return 0 + fi + if ! curl -fL --retry 3 -o "${STAGE_DIR}/${SCRIPTS_ASSET}" "$SCRIPTS_URL"; then + log "WARN: スクリプトの取得に失敗しました。サーバーの更新は完了しています" + rm -rf "$STAGE_DIR" + return 0 + fi + if ! tar -xzf "${STAGE_DIR}/${SCRIPTS_ASSET}" -C "$STAGE_DIR"; then + log "WARN: スクリプトの展開に失敗しました。サーバーの更新は完了しています" + rm -rf "$STAGE_DIR" + return 0 + fi + + local all_ok=true + local script src + for script in $MANAGED_SCRIPTS; do + src="${STAGE_DIR}/raspberry-pi/${script}" + if [ ! -f "$src" ]; then + log "WARN: 配布物に含まれていません: $script" + all_ok=false + continue + fi + chmod +x "$src" + # 実行中の自分自身を上書きすると bash の読み込みが壊れるため、 + # 同一ファイルシステム上に展開してから mv で差し替える + if mv -f "$src" "${APP_DIR}/${script}"; then + log "更新しました: ${APP_DIR}/${script}" + else + log "WARN: 差し替えに失敗しました: ${APP_DIR}/${script}" + all_ok=false + fi + done + rm -rf "$STAGE_DIR" + + if [ "$all_ok" = true ]; then + echo "$NEW_VERSION" > "$SCRIPTS_VERSION_FILE" + fi +} + # APK の登録に失敗してもサーバーの更新は完了しているため、警告のみで成功扱いにする sync_apk() { if [ "$SKIP_APK" = true ]; then @@ -82,24 +152,30 @@ if [ -n "$LOCAL_JAR" ]; then else command -v python3 >/dev/null || fail "python3 が必要です(Raspberry Pi OS には標準搭載)" log "GitHub Releases から最新の ${ASSET_NAME} を探しています..." - RELEASE_INFO=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=20" | python3 -c ' -import json, sys + RELEASE_INFO=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=20" | + ASSET_NAME="$ASSET_NAME" SCRIPTS_ASSET="$SCRIPTS_ASSET" python3 -c ' +import json, os, sys +jar_name = os.environ["ASSET_NAME"] +scripts_name = os.environ["SCRIPTS_ASSET"] for release in json.load(sys.stdin): if release.get("draft") or release.get("prerelease"): continue - for asset in release.get("assets", []): - if asset["name"] == "'"$ASSET_NAME"'": - print(release["tag_name"]) - print(asset["browser_download_url"]) - sys.exit(0) + assets = {a["name"]: a["browser_download_url"] for a in release.get("assets", [])} + if jar_name in assets: + print(release["tag_name"]) + print(assets[jar_name]) + print(assets.get(scripts_name, "")) + sys.exit(0) sys.exit(1) ') || fail "${ASSET_NAME} を含むリリースが見つかりません" NEW_VERSION=$(echo "$RELEASE_INFO" | sed -n 1p) DOWNLOAD_URL=$(echo "$RELEASE_INFO" | sed -n 2p) + SCRIPTS_URL=$(echo "$RELEASE_INFO" | sed -n 3p) CURRENT_VERSION=$(cat "$VERSION_FILE" 2>/dev/null || echo "none") if [ "$NEW_VERSION" = "$CURRENT_VERSION" ] && [ "$FORCE" = false ]; then log "すでに最新です($CURRENT_VERSION)。強制更新は --force を付けてください。" + self_update sync_apk exit 0 fi @@ -175,4 +251,5 @@ done log "更新が完了しました: $NEW_VERSION" +self_update sync_apk From 20a4921f1b22f46421edd78d26c7e69631c1294e Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 17:31:19 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=99=82=E3=81=AB=20e-Paper=20=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=82=82=E4=B8=80?= =?UTF-8?q?=E7=B7=92=E3=81=AB=E6=9B=B4=E6=96=B0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update-app.sh が /opt/kidspos-display のモジュールを配布物で差し替え、稼働中なら kidspos-display を再起動する - 表示サービス未導入の Pi では何もしない。差し替え不要なら --skip-display - 差し替えや再起動に失敗した場合はスクリプトのバージョンを記録せず次回に再試行する - 上記の経路を test-update-app.sh に追加(141 assertions green) --- scripts/raspberry-pi-display/README.md | 11 ++ scripts/raspberry-pi/README.md | 5 +- scripts/raspberry-pi/test-update-app.sh | 208 +++++++++++++++++++++++- scripts/raspberry-pi/update-app.sh | 66 ++++++++ 4 files changed, 286 insertions(+), 4 deletions(-) diff --git a/scripts/raspberry-pi-display/README.md b/scripts/raspberry-pi-display/README.md index 17310d2..2c3def3 100644 --- a/scripts/raspberry-pi-display/README.md +++ b/scripts/raspberry-pi-display/README.md @@ -87,6 +87,17 @@ sudo ./install-display.sh --skip-deps # 依存の確認を省略する 再実行しても設定は壊れません。systemd ユニットは内容が変わったときだけ書き換え、サービスが稼働中なら配置し直したコードを読ませるために再起動します。 実行ユーザーが spi / gpio グループに入っていなければ追加します。グループ追加を反映するには一度ログインし直してください。 +## 更新 + +導入後のコード更新はサーバーの更新に相乗りします。 + +```bash +sudo /opt/kidspos/update-app.sh +``` + +update-app.sh はリリースに同梱された表示サービスのコードで /opt/kidspos-display を差し替え、稼働中なら kidspos-display を再起動します。 +表示サービスだけを更新したい場合や systemd ユニット自体を変えたい場合は、リポジトリを取得し直して install-display.sh を実行してください。 + ## 起動と自動復旧 install-display.sh を実行すると、配置・自動起動の有効化・起動までを一度に行います。 diff --git a/scripts/raspberry-pi/README.md b/scripts/raspberry-pi/README.md index 44736f5..298707e 100644 --- a/scripts/raspberry-pi/README.md +++ b/scripts/raspberry-pi/README.md @@ -69,7 +69,10 @@ APK を確認せずサーバーだけ更新したい場合は --skip-apk を付 実行中のスクリプトを壊さないよう、一時ディレクトリに展開してから差し替えるため、新しい内容は次回の実行から反映されます。 差し替えたバージョンは .installed-scripts-version に記録され、失敗した場合は記録せず次回に再試行します。 取得や展開に失敗しても警告のみでサーバーの更新は成功扱いです。スクリプトを差し替えたくない場合は --skip-self-update を付けてください。 -なお install.sh と systemd ユニットは daemon-reload を伴うため自動更新の対象外です。変更があった場合はリポジトリを取得し直して install.sh を実行してください。 + +同じ配布物には e-Paper 表示サービスのコード(raspberry-pi-display)も含まれており、/opt/kidspos-display が存在すればそちらも同時に差し替え、稼働中なら kidspos-display を再起動します。 +表示サービスを導入していない Pi では何も行いません。差し替えたくない場合は --skip-display を付けてください。 +なお install.sh と install-display.sh と systemd ユニットは daemon-reload を伴うため自動更新の対象外です。変更があった場合はリポジトリを取得し直して各インストールスクリプトを実行してください。 ### オフライン時(jar を持ち込む場合) diff --git a/scripts/raspberry-pi/test-update-app.sh b/scripts/raspberry-pi/test-update-app.sh index 8301f49..5260a38 100755 --- a/scripts/raspberry-pi/test-update-app.sh +++ b/scripts/raspberry-pi/test-update-app.sh @@ -7,6 +7,7 @@ UPDATE_SCRIPT="${SCRIPT_DIR}/update-app.sh" PASS_COUNT=0 FAIL_COUNT=0 CURRENT_TEST="" +DISPLAY_MODULES="app.py config.py health.py layout.py renderer.py epaper.py" pass() { PASS_COUNT=$((PASS_COUNT + 1)) @@ -48,7 +49,8 @@ setup() { WORK=$(mktemp -d) APP_DIR="${WORK}/app" STUB_DIR="${WORK}/bin" - mkdir -p "$APP_DIR" "$STUB_DIR" + DISPLAY_DIR="${WORK}/display" + mkdir -p "$APP_DIR" "$STUB_DIR" "$DISPLAY_DIR" export CALL_LOG="${WORK}/calls.log" export HEALTH_OK_FILE="${WORK}/health-ok" @@ -57,8 +59,17 @@ setup() { export SCRIPTS_TARBALL_FILE="${WORK}/scripts.tar.gz" export SCRIPTS_DOWNLOAD_EXIT=0 export UPLOAD_APK_EXIT=0 + export DISPLAY_INACTIVE_FILE="${WORK}/display-inactive" + export DISPLAY_RESTART_EXIT=0 : > "$CALL_LOG" + DISPLAY_TARBALL_MODULES="$DISPLAY_MODULES" + + local module + for module in $DISPLAY_MODULES; do + printf '# old-display-marker\n' > "${DISPLAY_DIR}/${module}" + done + cat > "${STUB_DIR}/sudo" <<'EOF' #!/usr/bin/env bash exec "$@" @@ -71,6 +82,12 @@ if [ "${1:-}" = "start" ] && [ -f "$FAIL_START_ONCE_FILE" ]; then rm -f "$FAIL_START_ONCE_FILE" exit 1 fi +if [ "${1:-}" = "is-active" ] && [ -f "$DISPLAY_INACTIVE_FILE" ]; then + exit 3 +fi +if [ "${1:-}" = "restart" ]; then + exit "${DISPLAY_RESTART_EXIT:-0}" +fi exit 0 EOF @@ -164,7 +181,41 @@ EOF printf '#!/usr/bin/env bash\n# new-script-marker\n' > "${src}/${name}" fi done - tar -czf "$SCRIPTS_TARBALL_FILE" -C "${WORK}/scripts-src" raspberry-pi + + local entries=(raspberry-pi) + if [ -n "$DISPLAY_TARBALL_MODULES" ]; then + local display_src="${WORK}/scripts-src/raspberry-pi-display" + mkdir -p "$display_src" + local module + for module in $DISPLAY_TARBALL_MODULES; do + printf '# new-display-marker\n' > "${display_src}/${module}" + done + entries+=(raspberry-pi-display) + fi + + tar -czf "$SCRIPTS_TARBALL_FILE" -C "${WORK}/scripts-src" "${entries[@]}" +} + +assert_display_untouched() { + local module + for module in $DISPLAY_MODULES; do + if grep -q "new-display-marker" "${DISPLAY_DIR}/${module}" 2>/dev/null; then + fail_assert "$1" + return + fi + done + pass "$1" +} + +assert_display_replaced() { + local module + for module in $DISPLAY_MODULES; do + if ! grep -q "new-display-marker" "${DISPLAY_DIR}/${module}" 2>/dev/null; then + fail_assert "$1 (未差し替え: ${module})" + return + fi + done + pass "$1" } assert_scripts_untouched() { @@ -179,6 +230,7 @@ run_update() { set +e env PATH="${STUB_DIR}:${PATH}" \ KIDSPOS_APP_DIR="$APP_DIR" \ + KIDSPOS_DISPLAY_DIR="$DISPLAY_DIR" \ KIDSPOS_HEALTH_RETRIES=2 \ bash "$UPDATE_SCRIPT" "$@" > "${WORK}/out.log" 2>&1 RC=$? @@ -260,6 +312,7 @@ test_old_backups_are_pruned() { set +e env PATH="${STUB_DIR}:${PATH}" \ KIDSPOS_APP_DIR="$APP_DIR" \ + KIDSPOS_DISPLAY_DIR="$DISPLAY_DIR" \ KIDSPOS_HEALTH_RETRIES=2 \ KIDSPOS_BACKUP_KEEP=2 \ bash "$UPDATE_SCRIPT" "${WORK}/new.jar" > "${WORK}/out.log" 2>&1 @@ -289,6 +342,7 @@ test_health_check_uses_timeout() { set +e env PATH="${STUB_DIR}:${PATH}" \ KIDSPOS_APP_DIR="$APP_DIR" \ + KIDSPOS_DISPLAY_DIR="$DISPLAY_DIR" \ KIDSPOS_HEALTH_RETRIES=2 \ KIDSPOS_HEALTH_TIMEOUT=3 \ bash "$UPDATE_SCRIPT" "${WORK}/new.jar" > "${WORK}/out.log" 2>&1 @@ -478,7 +532,7 @@ test_self_update_when_jar_already_latest() { assert_eq 0 "$RC" "終了コードが 0" assert_contains "${WORK}/out.log" "すでに最新です" "最新である旨が出力される" assert_contains "${APP_DIR}/doctor.sh" "new-script-marker" "doctor.sh が差し替わる" - assert_not_contains "$CALL_LOG" "systemctl" "サービスは操作されない" + assert_not_contains "$CALL_LOG" "kidspos-server" "サーバーのサービスは操作されない" teardown } @@ -601,6 +655,146 @@ test_self_update_not_run_when_rolled_back() { teardown } +test_display_update_replaces_modules() { + setup "リリースに同梱された表示サービスのコードが差し替えられる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_display_replaced "表示サービスのコードが差し替わる" + assert_contains "${WORK}/out.log" "表示サービスを更新しました" "更新した旨が出力される" + assert_contains "$CALL_LOG" "systemctl restart kidspos-display" "表示サービスが再起動される" + assert_eq "v9.9.9" "$(cat "${APP_DIR}/.installed-scripts-version")" "スクリプトのバージョンが記録される" + if [ -x "${DISPLAY_DIR}/app.py" ]; then + pass "app.py に実行権限が付く" + else + fail_assert "app.py に実行権限が付く" + fi + teardown +} + +test_display_restarts_only_when_active() { + setup "表示サービスが停止中なら再起動しない" + touch "$HEALTH_OK_FILE" + touch "$DISPLAY_INACTIVE_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_display_replaced "表示サービスのコードが差し替わる" + assert_contains "${WORK}/out.log" "表示サービスは停止中のため再起動しません" "停止中である旨が出力される" + assert_not_contains "$CALL_LOG" "systemctl restart kidspos-display" "再起動は呼ばれない" + assert_eq "v9.9.9" "$(cat "${APP_DIR}/.installed-scripts-version")" "スクリプトのバージョンが記録される" + teardown +} + +test_display_skipped_when_dir_missing() { + setup "表示サービスが導入されていなければ何もしない" + touch "$HEALTH_OK_FILE" + rm -rf "$DISPLAY_DIR" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "表示サービスが導入されていないため更新しません" "スキップした旨が出力される" + assert_not_contains "$CALL_LOG" "systemctl restart kidspos-display" "再起動は呼ばれない" + assert_eq "v9.9.9" "$(cat "${APP_DIR}/.installed-scripts-version")" "スクリプトのバージョンが記録される" + if [ -d "$DISPLAY_DIR" ]; then + fail_assert "配置先は作成されない" + else + pass "配置先は作成されない" + fi + teardown +} + +test_display_skipped_with_flag() { + setup "--skip-display を付けると表示サービスは差し替えられない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + run_update --skip-display + + assert_eq 0 "$RC" "終了コードが 0" + assert_display_untouched "表示サービスのコードは差し替わらない" + assert_contains "${APP_DIR}/doctor.sh" "new-script-marker" "スクリプトは差し替わる" + assert_not_contains "$CALL_LOG" "systemctl restart kidspos-display" "再起動は呼ばれない" + assert_eq "v9.9.9" "$(cat "${APP_DIR}/.installed-scripts-version")" "スクリプトのバージョンが記録される" + teardown +} + +test_display_partial_tarball_is_not_recorded() { + setup "配布物に足りない表示モジュールがあればバージョンを記録しない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + DISPLAY_TARBALL_MODULES="app.py config.py health.py renderer.py epaper.py" + make_scripts_tarball + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "配布物に含まれていません: raspberry-pi-display/layout.py" "不足が警告される" + assert_contains "${DISPLAY_DIR}/app.py" "new-display-marker" "含まれている分は差し替わる" + if [ -e "${APP_DIR}/.installed-scripts-version" ]; then + fail_assert "スクリプトのバージョンは記録されない" + else + pass "スクリプトのバージョンは記録されない" + fi + teardown +} + +test_display_missing_in_tarball() { + setup "配布物に表示サービスが無ければバージョンを記録しない" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + DISPLAY_TARBALL_MODULES="" + make_scripts_tarball + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "配布物に raspberry-pi-display が含まれていません" "不足が警告される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了扱いになる" + assert_display_untouched "表示サービスのコードは差し替わらない" + if [ -e "${APP_DIR}/.installed-scripts-version" ]; then + fail_assert "スクリプトのバージョンは記録されない" + else + pass "スクリプトのバージョンは記録されない" + fi + teardown +} + +test_display_restart_failure_does_not_fail_update() { + setup "表示サービスの再起動に失敗してもサーバーの更新は成功扱いになる" + touch "$HEALTH_OK_FILE" + write_release_json_with_scripts "v9.9.9" + make_scripts_tarball + export DISPLAY_RESTART_EXIT=1 + run_update + + assert_eq 0 "$RC" "終了コードが 0" + assert_contains "${WORK}/out.log" "表示サービスの再起動に失敗しました" "警告が出力される" + assert_contains "${WORK}/out.log" "更新が完了しました" "サーバーの更新は完了扱いになる" + if [ -e "${APP_DIR}/.installed-scripts-version" ]; then + fail_assert "スクリプトのバージョンは記録されない" + else + pass "スクリプトのバージョンは記録されない" + fi + teardown +} + +test_display_not_updated_for_local_jar() { + setup "持ち込んだ jar での更新では表示サービスは差し替えられない" + touch "$HEALTH_OK_FILE" + run_update "${WORK}/new.jar" + + assert_eq 0 "$RC" "終了コードが 0" + assert_display_untouched "表示サービスのコードは差し替わらない" + assert_not_contains "$CALL_LOG" "systemctl restart kidspos-display" "再起動は呼ばれない" + teardown +} + test_success_path test_health_check_failure_rolls_back test_start_failure_rolls_back @@ -628,6 +822,14 @@ test_self_update_extract_failure_does_not_fail_update test_self_update_partial_tarball_is_not_recorded test_self_update_skipped_for_local_jar test_self_update_not_run_when_rolled_back +test_display_update_replaces_modules +test_display_restarts_only_when_active +test_display_skipped_when_dir_missing +test_display_skipped_with_flag +test_display_partial_tarball_is_not_recorded +test_display_missing_in_tarball +test_display_restart_failure_does_not_fail_update +test_display_not_updated_for_local_jar echo "" echo "passed: $PASS_COUNT, failed: $FAIL_COUNT" diff --git a/scripts/raspberry-pi/update-app.sh b/scripts/raspberry-pi/update-app.sh index 2be675f..2633b73 100755 --- a/scripts/raspberry-pi/update-app.sh +++ b/scripts/raspberry-pi/update-app.sh @@ -13,6 +13,9 @@ SERVER_URL="${KIDSPOS_SERVER_URL:-http://localhost:8080}" ASSET_NAME="app.jar" SCRIPTS_ASSET="kidspos-scripts.tar.gz" MANAGED_SCRIPTS="update-app.sh doctor.sh upload-apk.sh" +DISPLAY_DIR="${KIDSPOS_DISPLAY_DIR:-/opt/kidspos-display}" +DISPLAY_SERVICE="${KIDSPOS_DISPLAY_SERVICE:-kidspos-display}" +DISPLAY_FILES="app.py config.py health.py layout.py renderer.py epaper.py" JAR_PATH="${APP_DIR}/${JAR_NAME}" DB_PATH="${APP_DIR}/kidspos.db" @@ -29,6 +32,7 @@ usage() { echo " sudo $0 --force 同一バージョンでも強制的に再インストール" echo " sudo $0 --skip-apk サーバーの更新のみ行い、APK の確認は行わない" echo " sudo $0 --skip-self-update スクリプト自身の更新は行わない" + echo " sudo $0 --skip-display e-Paper 表示サービスの更新は行わない" exit 1 } @@ -38,6 +42,7 @@ fail() { echo "[update-app] ERROR: $*" >&2; exit 1; } FORCE=false SKIP_APK=false SKIP_SELF_UPDATE=false +SKIP_DISPLAY=false LOCAL_JAR="" SCRIPTS_URL="" for arg in "$@"; do @@ -45,6 +50,7 @@ for arg in "$@"; do --force) FORCE=true ;; --skip-apk) SKIP_APK=true ;; --skip-self-update) SKIP_SELF_UPDATE=true ;; + --skip-display) SKIP_DISPLAY=true ;; -h|--help) usage ;; -*) echo "[update-app] 不明なオプション: $arg" >&2 @@ -54,6 +60,63 @@ for arg in "$@"; do esac done +# 表示サービスを導入していない Pi もあるため、配置先が無ければ何もしない。 +# 差し替えに失敗した場合は呼び出し元でバージョンを記録させず、次回の更新で再試行させる +update_display() { + if [ "$SKIP_DISPLAY" = true ]; then + return 0 + fi + if [ ! -d "$DISPLAY_DIR" ]; then + log "表示サービスが導入されていないため更新しません: $DISPLAY_DIR" + return 0 + fi + + local staged="${STAGE_DIR}/raspberry-pi-display" + if [ ! -d "$staged" ]; then + log "WARN: 配布物に raspberry-pi-display が含まれていません" + return 1 + fi + + local ok=true + local module src + for module in $DISPLAY_FILES; do + src="${staged}/${module}" + if [ ! -f "$src" ]; then + log "WARN: 配布物に含まれていません: raspberry-pi-display/${module}" + ok=false + continue + fi + if ! mv -f "$src" "${DISPLAY_DIR}/${module}"; then + log "WARN: 差し替えに失敗しました: ${DISPLAY_DIR}/${module}" + ok=false + continue + fi + # install-display.sh がサービス実行ユーザーに所有者を合わせているため、 + # root で置き直したファイルも配置先ディレクトリと同じ所有者に戻す + if ! chown --reference="$DISPLAY_DIR" "${DISPLAY_DIR}/${module}"; then + log "WARN: 所有者を合わせられませんでした: ${DISPLAY_DIR}/${module}" + fi + done + if [ -f "${DISPLAY_DIR}/app.py" ]; then + chmod +x "${DISPLAY_DIR}/app.py" + fi + + if [ "$ok" != true ]; then + return 1 + fi + + log "表示サービスを更新しました: $DISPLAY_DIR" + if systemctl is-active --quiet "$DISPLAY_SERVICE"; then + if ! sudo systemctl restart "$DISPLAY_SERVICE"; then + log "WARN: 表示サービスの再起動に失敗しました: $DISPLAY_SERVICE" + return 1 + fi + else + log "表示サービスは停止中のため再起動しません: $DISPLAY_SERVICE" + fi + return 0 +} + # スクリプトの更新に失敗してもサーバーの更新は完了しているため、警告のみで成功扱いにする。 # 成功した分だけ差し替え、全て成功したときだけバージョンを記録して次回の再試行に備える self_update() { @@ -109,6 +172,9 @@ self_update() { all_ok=false fi done + if ! update_display; then + all_ok=false + fi rm -rf "$STAGE_DIR" if [ "$all_ok" = true ]; then From 008ca4665fcbdf0cd1f539a3563656cdeb6bfebb Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 17:43:18 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=E3=83=AC=E3=82=B7=E3=83=BC=E3=83=88?= =?UTF-8?q?=E5=8D=B0=E5=88=B7=E3=82=92=E9=9D=9E=E5=90=8C=E6=9C=9F=E5=8C=96?= =?UTF-8?q?=E3=81=97=E4=BC=9A=E8=A8=88=E3=83=AC=E3=82=B9=E3=83=9D=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=82=92=E5=BE=85=E3=81=9F=E3=81=9B=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit プリンターに到達できないとき、接続が OS 既定のタイムアウトまで ブロックして会計 API がタイムアウトしていた。 - ReceiptPrinter.print に接続タイムアウトと読み書きタイムアウトを追加 - ReceiptService.printReceiptAsync を新設し、送信のみ専用スレッドへ委譲 - プリンター未設定・店舗なしの場合は送信を依頼せず即座に返す - 接続タイムアウトを app.receipt.printer.connect-timeout-millis で設定可能に --- .../kidspos/receipt/ReceiptPrinter.kt | 10 ++- .../kidspos/server/config/AppProperties.kt | 1 + .../controller/api/SaleApiController.kt | 4 +- .../kidspos/server/service/ReceiptService.kt | 66 +++++++++++--- src/main/resources/application.yaml | 1 + .../kidspos/receipt/ReceiptPrinterTest.kt | 78 ++++++++++++++++ .../controller/api/SaleApiControllerTest.kt | 4 +- .../api/SaleApiControllerUnitTest.kt | 4 +- .../server/service/ReceiptServiceTest.kt | 88 +++++++++++++++++++ 9 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 src/test/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinterTest.kt diff --git a/src/main/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinter.kt b/src/main/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinter.kt index 1ba91b9..2f60b88 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinter.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinter.kt @@ -4,6 +4,7 @@ import info.nukoneko.kidspos.common.PrintCommand import info.nukoneko.kidspos.common.toAllEm import info.nukoneko.kidspos.common.toEm import java.io.IOException +import java.net.InetSocketAddress import java.net.Socket import java.nio.charset.Charset import java.text.SimpleDateFormat @@ -85,16 +86,21 @@ class ReceiptPrinter( command.newLine() } + // 到達できないプリンターへの接続は OS 既定では数分待たされるため、必ず上限を設ける @Throws(IOException::class) - fun print() { - Socket(ipOrHost, port).use { socket -> + fun print(timeoutMillis: Int = DEFAULT_TIMEOUT_MILLIS) { + Socket().use { socket -> + socket.soTimeout = timeoutMillis + socket.connect(InetSocketAddress(ipOrHost, port), timeoutMillis) socket.getOutputStream().use { it.write(command.build()) + it.flush() } } } companion object { + const val DEFAULT_TIMEOUT_MILLIS = 3_000 private const val MAX_ROW_TEXT_NUM = 20 private val dateFormat = SimpleDateFormat("yyyy年MM月dd日(E) HH時mm分ss秒") diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/config/AppProperties.kt b/src/main/kotlin/info/nukoneko/kidspos/server/config/AppProperties.kt index 96e5a6a..445f45a 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/config/AppProperties.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/config/AppProperties.kt @@ -15,6 +15,7 @@ data class AppProperties( val host: String = "localhost", val port: Int = 9100, val statusCacheSeconds: Int = 15, + val connectTimeoutMillis: Int = 3_000, ) } diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiController.kt index a02dc14..b1f9347 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiController.kt @@ -51,7 +51,7 @@ class SaleApiController( when (val result = saleProcessingService.processSaleWithValidation(saleBean, items)) { is SaleResult.Success -> { // Print receipt - receiptService.printReceipt( + receiptService.printReceiptAsync( request.storeId, items, request.deposit, @@ -106,7 +106,7 @@ class SaleApiController( when (val result = saleProcessingService.processSaleWithValidation(saleBean, items)) { is SaleResult.Success -> { // Print receipt - receiptService.printReceipt( + receiptService.printReceiptAsync( saleBean.storeId, items, saleBean.deposit, diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ReceiptService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ReceiptService.kt index 541ca40..fcdcdda 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/ReceiptService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ReceiptService.kt @@ -5,10 +5,15 @@ import info.nukoneko.kidspos.receipt.ReceiptPrinter import info.nukoneko.kidspos.server.config.AppProperties import info.nukoneko.kidspos.server.controller.dto.request.ItemBean import info.nukoneko.kidspos.server.entity.ItemEntity +import jakarta.annotation.PreDestroy import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.io.IOException import java.util.* +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit /** * Service responsible for receipt operations @@ -21,29 +26,61 @@ class ReceiptService( ) { private val logger = LoggerFactory.getLogger(ReceiptService::class.java) + private val executor = + ThreadPoolExecutor( + PRINT_THREADS, + PRINT_THREADS, + KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, + LinkedBlockingQueue(QUEUE_CAPACITY), + { runnable -> Thread(runnable, "receipt-printer").apply { isDaemon = true } }, + ).apply { allowCoreThreadTimeOut(true) } + /** - * Print receipt for a sale + * レシート印刷を依頼する + * + * 到達できないプリンターは接続タイムアウトまで待たされるため、 + * 送信は別スレッドに任せて会計のレスポンスを待たせない。 + * 戻り値は印刷を実際に依頼できたかどうかで、印刷の成否ではない。 */ - fun printReceipt( + fun printReceiptAsync( storeId: Int, items: List, deposit: Int, ): Boolean { logger.debug("Printing receipt for store: {}, items: {}", storeId, items.size) - return try { - val receiptDetail = createReceiptDetail(storeId, items, deposit) - val printerIp = getPrinterIp(storeId) ?: return false + val printerIp = getPrinterIp(storeId) ?: return false - sendToPrinter(printerIp, receiptDetail) - logger.info("Receipt printed successfully for store: {}", storeId) + val receiptDetail = + try { + createReceiptDetail(storeId, items, deposit) + } catch (e: Exception) { + logger.error("Failed to build receipt for store: {}", storeId, e) + return false + } + + return try { + executor.execute { + try { + sendToPrinter(printerIp, receiptDetail) + logger.info("Receipt printed successfully for store: {}", storeId) + } catch (e: Exception) { + logger.error("Failed to print receipt for store: {}", storeId, e) + } + } true - } catch (e: Exception) { - logger.error("Failed to print receipt for store: {}", storeId, e) + } catch (e: RejectedExecutionException) { + logger.error("Print queue is full, receipt discarded for store: {}", storeId, e) false } } + @PreDestroy + fun shutdown() { + executor.shutdownNow() + } + /** * Create receipt detail object */ @@ -99,15 +136,16 @@ class ReceiptService( printerIp: String, receiptDetail: ReceiptDetail, ) { + val printerProperties = appProperties.receipt.printer val printer = ReceiptPrinter( printerIp, - appProperties.receipt.printer.port, + printerProperties.port, receiptDetail, ) try { - printer.print() + printer.print(printerProperties.connectTimeoutMillis) logger.debug("Receipt sent to printer at: {}", printerIp) } catch (e: IOException) { logger.error("Failed to send receipt to printer at {}: {}", printerIp, e.message, e) @@ -147,4 +185,10 @@ class ReceiptService( * Validate printer configuration for store */ fun validatePrinterConfiguration(storeId: Int): Boolean = getPrinterIp(storeId) != null + + companion object { + const val PRINT_THREADS = 2 + const val KEEP_ALIVE_SECONDS = 60L + const val QUEUE_CAPACITY = 64 + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c216cbd..847d730 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,6 +50,7 @@ app: host: ${RECEIPT_PRINTER_HOST:localhost} port: ${RECEIPT_PRINTER_PORT:9100} status-cache-seconds: ${RECEIPT_PRINTER_STATUS_CACHE_SECONDS:15} + connect-timeout-millis: ${RECEIPT_PRINTER_CONNECT_TIMEOUT_MILLIS:3000} barcode: qr-size: ${QR_CODE_SIZE:200} pdf: diff --git a/src/test/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinterTest.kt b/src/test/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinterTest.kt new file mode 100644 index 0000000..3d732dc --- /dev/null +++ b/src/test/kotlin/info/nukoneko/kidspos/receipt/ReceiptPrinterTest.kt @@ -0,0 +1,78 @@ +package info.nukoneko.kidspos.receipt + +import info.nukoneko.kidspos.server.entity.ItemEntity +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.ServerSocket +import java.util.Date +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit + +class ReceiptPrinterTest { + private fun detail() = + ReceiptDetail( + items = listOf(ItemEntity(id = 1, barcode = "0123456789", name = "あめ", price = 100)), + storeName = "テスト店", + deposit = 500, + transactionId = "0123456789", + createdAt = Date(), + ) + + @Test + fun `プリンターへ印字データを送信する`() { + val received = ArrayBlockingQueue(1) + ServerSocket(0).use { server -> + Thread { + server.accept().use { socket -> + received.put(socket.getInputStream().readBytes()) + } + }.apply { isDaemon = true }.start() + + ReceiptPrinter("127.0.0.1", server.localPort, detail()).print() + + val bytes = received.poll(WAIT_LIMIT_MILLIS, TimeUnit.MILLISECONDS) + assertTrue(bytes != null && bytes.isNotEmpty(), "印字データが送信される") + } + } + + @Test + fun `接続を拒否されたら待たずに失敗する`() { + val closedPort = ServerSocket(0).use { it.localPort } + + val elapsedMillis = + measureMillis { + assertThrows(IOException::class.java) { + ReceiptPrinter("127.0.0.1", closedPort, detail()).print(TIMEOUT_MILLIS) + } + } + + assertTrue(elapsedMillis < WAIT_LIMIT_MILLIS, "すぐに失敗する: ${elapsedMillis}ms") + } + + @Test + fun `到達できないプリンターは指定したタイムアウトで打ち切る`() { + val elapsedMillis = + measureMillis { + assertThrows(IOException::class.java) { + ReceiptPrinter(UNROUTABLE_HOST, 9100, detail()).print(TIMEOUT_MILLIS) + } + } + + assertTrue(elapsedMillis < WAIT_LIMIT_MILLIS, "指定したタイムアウトで打ち切られる: ${elapsedMillis}ms") + } + + private fun measureMillis(block: () -> Unit): Long { + val startedAt = System.nanoTime() + block() + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + } + + companion object { + // RFC 5737 のドキュメント用アドレス。経路が無いため接続タイムアウトの経路を通る + private const val UNROUTABLE_HOST = "192.0.2.1" + private const val TIMEOUT_MILLIS = 300 + private const val WAIT_LIMIT_MILLIS = 5_000L + } +} diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerTest.kt index e51600b..cc75005 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerTest.kt @@ -96,7 +96,7 @@ class SaleApiControllerTest { `when`(itemParsingService.parseItemsFromIds("1,2")).thenReturn(testItems) `when`(saleProcessingService.processSaleWithValidation(any(), any())) .thenReturn(SaleResult.Success(testSale, summary)) - `when`(receiptService.printReceipt(any(), any(), any())).thenReturn(true) + `when`(receiptService.printReceiptAsync(any(), any(), any())).thenReturn(true) // When & Then mockMvc @@ -139,7 +139,7 @@ class SaleApiControllerTest { verify(itemParsingService).parseItemsFromIds("1,2") verify(saleProcessingService).processSaleWithValidation(any(), any()) - verify(receiptService, never()).printReceipt(any(), any(), any()) + verify(receiptService, never()).printReceiptAsync(any(), any(), any()) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerUnitTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerUnitTest.kt index 92c657d..6c51dad 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerUnitTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/SaleApiControllerUnitTest.kt @@ -87,7 +87,7 @@ class SaleApiControllerUnitTest { `when`(itemParsingService.parseItemsFromIds("1,2")).thenReturn(testItems) `when`(saleProcessingService.processSaleWithValidation(expectedSaleBean, testItems)) .thenReturn(SaleResult.Success(testSale, summary)) - `when`(receiptService.printReceipt(1, testItems, 400)).thenReturn(true) + `when`(receiptService.printReceiptAsync(1, testItems, 400)).thenReturn(true) // When val result = controller.createSale(request) @@ -105,7 +105,7 @@ class SaleApiControllerUnitTest { verify(itemParsingService).parseItemsFromIds("1,2") verify(saleProcessingService).processSaleWithValidation(expectedSaleBean, testItems) - verify(receiptService).printReceipt(1, testItems, 400) + verify(receiptService).printReceiptAsync(1, testItems, 400) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ReceiptServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ReceiptServiceTest.kt index 04688df..aa41f98 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ReceiptServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ReceiptServiceTest.kt @@ -10,6 +10,9 @@ import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import java.net.ServerSocket +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit @SpringBootTest class ReceiptServiceTest { @@ -157,4 +160,89 @@ class ReceiptServiceTest { assertTrue(result.contains("Change: 100リバー")) verify(storeService).findStore(storeId) } + + @Test + fun `should not request printing when printer is not configured`() { + val storeId = 1 + `when`(storeService.findStore(storeId)).thenReturn(StoreEntity(storeId, "Test Store", "")) + + val service = receiptServiceWith(port = 9100) + + assertFalse(service.printReceiptAsync(storeId, printableItems, 1000)) + } + + @Test + fun `should not request printing when store does not exist`() { + val storeId = 999 + `when`(storeService.findStore(storeId)).thenReturn(null) + + val service = receiptServiceWith(port = 9100) + + assertFalse(service.printReceiptAsync(storeId, printableItems, 1000)) + } + + @Test + fun `should send receipt to printer in background`() { + val storeId = 1 + val received = ArrayBlockingQueue(1) + + ServerSocket(0).use { server -> + Thread { + server.accept().use { socket -> + received.put(socket.getInputStream().readBytes()) + } + }.apply { isDaemon = true }.start() + + `when`(storeService.findStore(storeId)).thenReturn(StoreEntity(storeId, "Test Store", "127.0.0.1")) + val service = receiptServiceWith(port = server.localPort) + + assertTrue(service.printReceiptAsync(storeId, printableItems, 1000)) + + val bytes = received.poll(WAIT_LIMIT_MILLIS, TimeUnit.MILLISECONDS) + assertTrue(bytes != null && bytes.isNotEmpty(), "印字データが送信される") + } + } + + @Test + fun `should return immediately when printer is unreachable`() { + val storeId = 1 + `when`(storeService.findStore(storeId)).thenReturn(StoreEntity(storeId, "Test Store", UNROUTABLE_HOST)) + + val service = receiptServiceWith(port = 9100, connectTimeoutMillis = CONNECT_TIMEOUT_MILLIS) + + val startedAt = System.nanoTime() + assertTrue(service.printReceiptAsync(storeId, printableItems, 1000)) + val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + assertTrue(elapsedMillis < CONNECT_TIMEOUT_MILLIS, "接続を待たずに返る: ${elapsedMillis}ms") + } + + private fun receiptServiceWith( + port: Int, + connectTimeoutMillis: Int = CONNECT_TIMEOUT_MILLIS, + ): ReceiptService { + val properties = + AppProperties( + receipt = + AppProperties.ReceiptProperties( + printer = + AppProperties.ReceiptProperties.PrinterProperties( + port = port, + connectTimeoutMillis = connectTimeoutMillis, + ), + ), + ) + return ReceiptService(storeService, properties) + } + + companion object { + private const val UNROUTABLE_HOST = "192.0.2.1" + private const val CONNECT_TIMEOUT_MILLIS = 3_000 + private const val WAIT_LIMIT_MILLIS = 5_000L + private val printableItems = + listOf( + ItemBean(1, "0123456789", "あめ", 100), + ItemBean(2, "9876543210", "ガム", 200), + ) + } } From cdf825e74b27ab0beff6a459ad78e99a51c8a003 Mon Sep 17 00:00:00 2001 From: atsumi Date: Mon, 24 Aug 2026 17:55:07 +0000 Subject: [PATCH 6/6] =?UTF-8?q?APK=E7=94=BB=E9=9D=A2=E3=81=A7=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=83=A7=E3=83=B3=E9=A0=86=E5=BA=8F=E3=81=AE?= =?UTF-8?q?=E9=80=86=E8=BB=A2=E3=82=92=E8=AD=A6=E5=91=8A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最新バージョンはバージョンコードの降順で決まるため、手入力ミスで 実際より大きいコードが登録されると古いAPKが配信され続ける。 バージョン名の新しさとコードの大小が食い違う組み合わせを検出し、 APK管理画面に警告として表示する。 --- .../server/controller/front/ApkController.kt | 1 + .../server/service/ApkVersionService.kt | 61 +++++++++++++++ src/main/resources/templates/apk/index.html | 15 ++++ .../controller/front/ApkControllerTest.kt | 53 +++++++++++++ .../server/service/ApkVersionServiceTest.kt | 78 +++++++++++++++++++ 5 files changed, 208 insertions(+) diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/front/ApkController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/front/ApkController.kt index 92a08a4..27baa81 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/front/ApkController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/front/ApkController.kt @@ -25,6 +25,7 @@ class ApkController( model.addAttribute("versions", versions) model.addAttribute("latestVersion", latestVersion) + model.addAttribute("versionOrderConflicts", apkVersionService.detectVersionOrderConflicts(versions)) model.addAttribute("title", "APKバージョン管理") return "apk/index" diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ApkVersionService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ApkVersionService.kt index d692614..77b572b 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/ApkVersionService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ApkVersionService.kt @@ -17,6 +17,14 @@ import java.nio.file.Paths import java.nio.file.StandardCopyOption import java.time.LocalDateTime +/** + * バージョン名の新しさとバージョンコードの大小が食い違う組み合わせ + */ +data class VersionOrderConflict( + val newerName: ApkVersionEntity, + val higherCode: ApkVersionEntity, +) + @Service @Transactional class ApkVersionService( @@ -142,6 +150,59 @@ class ApkVersionService( @Transactional(readOnly = true) fun getAllVersions(): List = apkVersionRepository.findAllByOrderByVersionCodeDesc() + /** + * バージョン名の新しさとバージョンコードの大小が食い違う組み合わせを洗い出す + * + * 最新バージョンはバージョンコードの降順で決まるため、 + * 手入力ミスで大きすぎるコードが登録されると古いAPKが配信され続ける。 + */ + fun detectVersionOrderConflicts(versions: List): List = + versions.indices.flatMap { left -> + (left + 1 until versions.size).mapNotNull { right -> + conflictBetween(versions[left], versions[right]) + } + } + + private fun conflictBetween( + left: ApkVersionEntity, + right: ApkVersionEntity, + ): VersionOrderConflict? { + val nameOrder = compareVersionNames(left.version, right.version) + val codeOrder = left.versionCode.compareTo(right.versionCode) + if (nameOrder == 0 || codeOrder == 0 || (nameOrder > 0) == (codeOrder > 0)) { + return null + } + return if (nameOrder > 0) { + VersionOrderConflict(newerName = left, higherCode = right) + } else { + VersionOrderConflict(newerName = right, higherCode = left) + } + } + + private fun compareVersionNames( + left: String, + right: String, + ): Int { + val leftParts = left.split('.') + val rightParts = right.split('.') + for (index in 0 until maxOf(leftParts.size, rightParts.size)) { + val leftPart = leftParts.getOrElse(index) { "" } + val rightPart = rightParts.getOrElse(index) { "" } + val leftNumber = leftPart.toIntOrNull() + val rightNumber = rightPart.toIntOrNull() + val order = + if (leftNumber != null && rightNumber != null) { + leftNumber.compareTo(rightNumber) + } else { + leftPart.compareTo(rightPart) + } + if (order != 0) { + return order + } + } + return 0 + } + @Transactional(readOnly = true) fun getVersionById(id: Long): ApkVersionEntity = apkVersionRepository diff --git a/src/main/resources/templates/apk/index.html b/src/main/resources/templates/apk/index.html index a8d3034..913bbb6 100644 --- a/src/main/resources/templates/apk/index.html +++ b/src/main/resources/templates/apk/index.html @@ -31,6 +31,21 @@

+
+
バージョン番号の並びが逆転しています
+

最新バージョンはバージョンコードの大きい順に決まります。次の組み合わせはバージョン名の新しさと逆になっているため、アプリに古いAPKが配信されます。誤って登録されたバージョンコードを削除してください。

+
    +
  • + + (コード ) + のほうが新しいバージョン名ですが、 + + (コード ) + のバージョンコードが大きいため最新として扱われます。 +
  • +
+
+
最新バージョン

diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/front/ApkControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/front/ApkControllerTest.kt index b450cf8..24fd23a 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/front/ApkControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/front/ApkControllerTest.kt @@ -4,6 +4,9 @@ import info.nukoneko.kidspos.server.domain.exception.InvalidFileException import info.nukoneko.kidspos.server.entity.ApkVersionEntity import info.nukoneko.kidspos.server.service.ApkManifestInfo import info.nukoneko.kidspos.server.service.ApkVersionService +import info.nukoneko.kidspos.server.service.VersionOrderConflict +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.not import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito.* @@ -34,6 +37,56 @@ class ApkControllerTest { contentType: String = "application/vnd.android.package-archive", ) = MockMultipartFile("file", name, contentType, ByteArray(100)) + @Test + fun `GET index should expose detected version order conflicts`() { + val mistyped = apkVersion(id = 1L, version = "1.0.10", versionCode = 100) + val newest = apkVersion(id = 2L, version = "1.0.11", versionCode = 12) + val conflicts = listOf(VersionOrderConflict(newerName = newest, higherCode = mistyped)) + val versions = listOf(mistyped, newest) + + whenever(apkVersionService.getAllVersions()).thenReturn(versions) + whenever(apkVersionService.getLatestVersion()).thenReturn(mistyped) + whenever(apkVersionService.detectVersionOrderConflicts(versions)).thenReturn(conflicts) + + mockMvc + .perform(get("/apk")) + .andExpect(status().isOk) + .andExpect(view().name("apk/index")) + .andExpect(model().attribute("versionOrderConflicts", conflicts)) + .andExpect(content().string(containsString("バージョン番号の並びが逆転しています"))) + .andExpect(content().string(containsString("1.0.11"))) + } + + @Test + fun `GET index should expose an empty conflict list when versions are consistent`() { + val versions = listOf(apkVersion(id = 1L, version = "1.0.11", versionCode = 12)) + + whenever(apkVersionService.getAllVersions()).thenReturn(versions) + whenever(apkVersionService.getLatestVersion()).thenReturn(versions.first()) + whenever(apkVersionService.detectVersionOrderConflicts(versions)).thenReturn(emptyList()) + + mockMvc + .perform(get("/apk")) + .andExpect(status().isOk) + .andExpect(model().attribute("versionOrderConflicts", emptyList())) + .andExpect(content().string(not(containsString("バージョン番号の並びが逆転しています")))) + } + + private fun apkVersion( + id: Long, + version: String, + versionCode: Int, + ) = ApkVersionEntity( + id = id, + version = version, + versionCode = versionCode, + fileName = "kidspos-v$version.apk", + fileSize = 1000L, + filePath = "/uploads/apk/kidspos-v$version.apk", + isActive = true, + uploadedAt = LocalDateTime.now(), + ) + @Test fun `POST analyze should return version information as JSON`() { whenever(apkVersionService.analyzeApk(any())).thenReturn(ApkManifestInfo("1.2.3", 10203)) diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ApkVersionServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ApkVersionServiceTest.kt index 62a99d2..a9eac6c 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ApkVersionServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ApkVersionServiceTest.kt @@ -247,6 +247,84 @@ class ApkVersionServiceTest { assertNull(result) } + @Test + fun `detectVersionOrderConflicts should report a version code that contradicts the version name`() { + val mistyped = versionOf(id = 1L, version = "1.0.10", versionCode = 100) + val newest = versionOf(id = 2L, version = "1.0.11", versionCode = 12) + + val conflicts = apkVersionService.detectVersionOrderConflicts(listOf(mistyped, newest)) + + assertEquals(1, conflicts.size) + assertEquals(newest, conflicts.first().newerName) + assertEquals(mistyped, conflicts.first().higherCode) + } + + @Test + fun `detectVersionOrderConflicts should return empty when version codes follow version names`() { + val versions = + listOf( + versionOf(id = 3L, version = "1.0.11", versionCode = 12), + versionOf(id = 2L, version = "1.0.10", versionCode = 11), + versionOf(id = 1L, version = "1.0.9", versionCode = 10), + ) + + assertTrue(apkVersionService.detectVersionOrderConflicts(versions).isEmpty()) + } + + @Test + fun `detectVersionOrderConflicts should compare version name segments numerically`() { + val versions = + listOf( + versionOf(id = 1L, version = "1.0.9", versionCode = 9), + versionOf(id = 2L, version = "1.0.10", versionCode = 10), + ) + + assertTrue(apkVersionService.detectVersionOrderConflicts(versions).isEmpty()) + } + + @Test + fun `detectVersionOrderConflicts should report every conflicting pair`() { + val mistyped = versionOf(id = 1L, version = "1.0.10", versionCode = 100) + val versions = + listOf( + mistyped, + versionOf(id = 2L, version = "1.0.11", versionCode = 12), + versionOf(id = 3L, version = "1.1.0", versionCode = 13), + ) + + val conflicts = apkVersionService.detectVersionOrderConflicts(versions) + + assertEquals(2, conflicts.size) + assertTrue(conflicts.all { it.higherCode == mistyped }) + assertEquals(listOf("1.0.11", "1.1.0"), conflicts.map { it.newerName.version }) + } + + @Test + fun `detectVersionOrderConflicts should ignore versions that share a version name`() { + val versions = + listOf( + versionOf(id = 1L, version = "1.0.0", versionCode = 1), + versionOf(id = 2L, version = "1.0.0", versionCode = 2), + ) + + assertTrue(apkVersionService.detectVersionOrderConflicts(versions).isEmpty()) + } + + private fun versionOf( + id: Long, + version: String, + versionCode: Int, + ) = ApkVersionEntity( + id = id, + version = version, + versionCode = versionCode, + fileName = "kidspos-v$version.apk", + fileSize = 1000L, + filePath = "$testUploadDir/kidspos-v$version.apk", + isActive = true, + uploadedAt = LocalDateTime.now(), + ) + @Test fun `getVersionById should return version when exists`() { // Given