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 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/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/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/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/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/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 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), + ) + } }