📌 この記事を5行でまとめると
-
Guest Demo第5段階を終え、本番公開後にコードベース全体をもう一度監査しました。
-
監査では Critical / Highは0件 でしたが、「今すぐ致命傷ではないものの、今後の機能追加前に直しておきたい点」がいくつか残っていました。
-
そこで今回は、バックアップファイルの保護、JST基準の日付処理、Dashboard期間表示、logout、Gemini timeout、PostgreSQL CIまでを Change A〜H として順番に整理しました。
-
最後には、SQLite中心の通常テストでは見えていなかった DailySales初回登録の並行競合 を実PostgreSQLで再現し、原子的UPSERTによって修正しています。
-
Guest Demoの防御開発は、ここで一区切りです。
はじめに
現在、ベーカリー向け売上管理Webアプリケーション
🍞 sales_data_app 🥖 を個人開発している tosane932 です。
商品登録・日次売上入力・Dashboardでの集計やランキング・AIによる売上分析などを実装しながら、実際の店舗でも迷わず使えるシステムを目指して開発を続けています。
GitHub
Guest Demo
Guest Demoでは、ログイン情報を入力せずに商品登録・売上入力・Dashboardなどを実際に操作できます。
主な構成は以下です。
- Python
- Flask
- PostgreSQL
- SQLAlchemy
- Alembic
- pytest
- GitHub Actions
- Gemini API
- Docker
- Render
これまでGuest Demoを、
- Dataset基盤
- Guest identity / session
- Dataset境界
- 実業務route開放
- 期限・cleanup・利用制限・公開前防御
という段階に分けて実装してきました。
前回の第5段階では、Guest期限・利用制限・公開前確認などを整え、本番公開まで進めています。
Guest Demo公開後に、コードベース全体を読み取り専用で再監査しました。
結果は、
Critical / High:0件
でした。
認証回避やDataset越境、cleanupによるAdmin誤削除、AIプロンプトへの他Dataset混入といった、公開を止めるほどの問題は確認されませんでした。
一方で、
「今すぐ大事故になるわけではないが、新しい業務機能を増やす前に直しておいた方がいい」
という改善点はいくつか残りました。
今回は、それらを Change A〜H として順番に整理していきます。
📋 Change A〜H一覧
| Change | 見つかった課題 | 対応 | 主な目的 |
|---|---|---|---|
| A | SQLiteバックアップがignore対象外 |
.gitignore / .dockerignoreを修正 |
DBファイル誤混入防止 |
| B | 業務日付がサーバーtimezone依存 |
business_today()でJST統一 |
月初・年始事故防止 |
| C | 商品登録yearが2026固定 | 年候補を動的生成 | 将来年度への対応 |
| D | Dashboard表示期間と集計条件が不一致 | HTML / API / AIの期間モデル統一 | 数字の誤解防止 |
| E | logout手段がない | POST + CSRF保護を追加 | 認証Sessionの安全な終了 |
| F | Geminiに明示timeoutなし | 15秒timeout追加 | AI障害の波及防止 |
| G | PostgreSQL専用testが通常CIでskip | PostgreSQL 16のCI job追加 | 本番DB固有の挙動を継続確認 |
| H | migration・並行処理保証が不足 | Alembic / cleanup / DailySales競合を実PostgreSQLで検証 | DB事故防止 |
A〜Fは主にアプリ側の仕上げ、G〜HはCI・PostgreSQL・並行処理の仕上げです。
新機能を増やす前に、公開後監査で見つかった宿題をここで一度片付けます。
Change A:SQLiteバックアップをGit / Dockerから守る
問題
ローカルには、意図的に保存しているSQLiteバックアップがありました。
local.db.backup-20260906-191727
しかし既存の、
*.db
*.bak
では、この名前はignore対象になっていませんでした。
なぜ危険なのか
例えば、
git add -A
を実行した場合、バックアップDBを誤ってstageする可能性があります。
Dockerfileでも、
COPY . .
を使用しているため、Docker build contextへ入る可能性もあります。
DBバックアップには商品や売上などのデータが含まれる可能性があるため、
「未追跡だから大丈夫」ではなく、仕組みで除外する
ことにしました。
対応
.gitignore と .dockerignore に、
local.db.backup-*
を追加しました。
既存のバックアップファイルそのものは削除・移動していません。
バックアップを人の注意だけで守るのではなく、GitとDockerの両方で最初から対象外にする方針です。
🔍 RED → GREENと確認内容
変更前:
git check-ignore -v local.db.backup-20260906-191727
exit code: 1
ignoreされていませんでした。
変更後:
.gitignore:11:local.db.backup-* local.db.backup-20260906-191727
Gitのignore対象になったことを確認しました。
全pytest:
381 passed
4 skipped
0 failed
Change B:店舗業務日付をJSTへ統一
問題
一部の業務処理で、
datetime.date.today()
など、実行環境のtimezoneに依存した日付判定が残っていました。
日本の店舗で使うアプリでも、サーバー側がUTCの場合、
日本時間:2026-10-01 00:30
UTC: 2026-09-30 15:30
という時間帯があります。
この状態でサーバー基準の「今日」を使うと、
日本では10月なのに9月の商品を表示する
といった事故につながります。
対応
Python標準ライブラリのzoneinfoを使い、
BUSINESS_TIMEZONE = ZoneInfo("Asia/Tokyo")
と、
business_today()
を追加しました。
店舗業務上の「今日」は、すべてJST基準にします。
一方で、
created_atlast_activity_atabsolute_expires_at- Guest期限
- cleanup
- rate limit
などの技術的な時刻管理は、これまでどおりUTCのまま維持しています。
店舗の日付はJST、技術的なtimestampはUTC
用途を分けることで、業務上の日付とシステム内部の時刻管理を混同しないようにしています。
1リクエスト内では同じ日付を使う
/inputでは、
商品抽出
当日売上
画面表示
の途中で日付が切り替わらないよう、1回取得したbusiness_today()を使い回すようにしました。
深夜0時ちょうど付近でも、
商品は9月
売上保存は10月
のような不整合を防げます。
🧪 JST境界pytest
追加した主なケースです。
UTC 2026-09-11 15:30
→ JST 2026-09-12
UTC 2026-09-30 15:30
→ JST 2026-10-01
UTC 2026-12-31 15:30
→ JST 2027-01-01
既存の月替わり・年替わりテストも、business_today()をmonkeypatchする方式へ変更。
最終結果:
385 passed
4 skipped
0 failed
Change C:「2026固定」をやめる
問題
Python側では現在年を動的に扱えるようになっていましたが、商品登録画面のHTMLには、
<option value="2026">2026</option>
という固定値が残っていました。
つまり2027年になると、
バックエンドは2027年を扱えるのに、画面から2027年を選べない
という状態になります。
対応
商品登録画面のyear候補を動的生成するよう変更しました。
通常は、
前年
現在年
翌年
の3年を表示。
さらに、そのDatasetに過去の商品が存在する場合のみ、その年度も候補へ追加します。
例えば、
JST 2026年12月
→ 2025 / 2026 / 2027
JST 2027年1月
→ 2026 / 2027 / 2028
2023年の商品が存在する場合は、
2023 / 2025 / 2026 / 2027
のようになります。
Dataset境界も維持
過去年を取得するときも、
現在のDatasetに存在するProductだけ
を対象にしています。
そのため、
Admin
Guest A
Guest B
の年度候補が混ざることはありません。
🧪 追加したpytest
- 2026年末でも2027年を選択可能
- 2027年になれば2027年が通常選択される
- 2028年も翌年候補になる
- 過年度商品を表示できる
- 別Datasetの年度は混ざらない
- validation error後も選択年を維持
最終結果:
389 passed
4 skipped
0 failed
Change D:Dashboardの「見えている期間」と「集計期間」を揃える
問題
今回、利用者目線で特に直しておきたかった部分です。
Dashboard初回表示では全年度を集計している一方、year selectには現在年が入っていました。
その状態で何も変更せず、
🔍 データを抽出
を押すと、
初回:全年度
再抽出:現在年
へ変わる可能性がありました。
しかも画面上では、どちらも、
全期間
と見える状態でした。
数字が正しくても、意味を誤解したら事故
システム内部の計算自体が正しくても、
「この数字が何年分なのか」
を利用者が誤解すれば、業務画面としては問題があります。
そこで期間を3種類に明確化しました。
全年度・全月
2026年・全月
2026年9月
HTML / API / AIで共通化
以下の3経路で、同じ期間正規化を使用します。
/dashboard
/api/dashboard-data
/api/ai-advice
さらに画面には、
📅 表示中の期間:2026年9月
のように、現在見ている期間を常時表示します。
Dashboard・API・AIで別々に期間を解釈するのではなく、同じ期間モデルを共有するようにしました。
year候補も実データ基準へ
DashboardではProduct登録年ではなく、
DailySalesが存在する年度
を候補にします。
例えば、
全年度
2023
2025
2026
という形です。
売上がない空年度を大量に並べません。
ただし現在年だけは、売上ゼロでも表示します。
月は1〜12月を固定
月については「売上がある月だけ」に絞りませんでした。
例えば、
1月 ✅
2月
3月 ✅
4月
...
と12か月を固定表示。
✅は、
DailySales rowが存在する月
を意味します。
quantity=0でもDailySales rowがあれば✅です。
AIも「表示中の期間」を見る
selectだけ変更して、
🔍 データを抽出
を押していない状態では、Dashboard表示自体はまだ以前の期間です。
そこでAIも、
最後に正常適用された期間
を使うようにしました。
これにより、
画面:2026年9月
AI:2025年
のような食い違いを防いでいます。
🧪 Dashboard期間pytest
主な確認内容:
- 初回全年度表示
- 同条件再抽出でも集計が変わらない
- 年度指定・全月
- 年月指定
- DailySales由来のyear候補
- Productだけ存在する年度は除外
- Dataset間のyear分離
- quantity 0の月✅
- 全年度時はmonthを全月へ正規化
- HTML / API / AIの期間一致
- AIは適用済み期間を利用
- month 0 / 13 / 負数を拒否
結果:
399 passed
4 skipped
0 failed
Change E:Admin / Guest共通のlogoutを追加
問題
それまでsales_data_appには、明示的なlogout機能がありませんでした。
特にAdminが共有PCを使った場合、
認証Sessionが残ったままになる
可能性があります。
対応
以下を追加しました。
POST /logout
GETではlogoutできません。
また、Flask-WTFによるCSRF保護を適用しています。
正常なlogoutでは、
- Flask-Login認証解除
_user_id_fresh_idadmin_auth_fingerprint
などを解除し、固定内部URLの、
/login
へredirectします。
Guest Datasetは削除しない
ここでは少し迷いました。
Guest logout時に、
Session終了
+
Dataset即時削除
まで行う案もあります。
しかし即時削除まで担当すると、
- 並行request
- cleanup
- rollback
- Product / DailySales削除
- 他Guest保護
まで新しい競合設計が必要になります。
そのため今回は責務を絞り、
logoutはSessionを終了するだけ
としました。
Guest Datasetは既存の、
無操作30分
絶対期限2時間
cleanup
へ任せます。
「logoutしたら全部削除する」ではなく、認証終了とデータcleanupの責務を分離しました。
現在の利用状態も表示
主要画面には、
利用中:管理者
または、
利用中:ゲストデモ
を表示。
その横に文字付きのlogoutボタンを配置しました。
🧪 logout pytest
確認内容:
- Admin正常logout
- Guest正常logout
- CSRF欠損 → 400
- CSRF改ざん → 400
- GET /logout → 405
- 二重POST
- logout後の保護route拒否
- Guest Dataset保持
- Product / DailySales保持
- Guest A logout時にGuest B / Admin不変
結果:
410 passed
4 skipped
0 failed
Change F:Geminiに15秒timeoutを追加
問題
Gemini APIには明示的なtimeoutがありませんでした。
その場合、
Gemini障害
ネットワーク障害
DNS障害
長時間応答なし
などでWeb workerが長時間待たされる可能性があります。
AI機能だけの問題が、
Webアプリ全体の応答性へ広がる
可能性があります。
対応
使用中のSDKは、
google-genai==2.10.0
です。
HttpOptions.timeoutがミリ秒指定であることを確認し、
genai.Client(
http_options={"timeout": 15_000}
)
としました。
つまり、
15,000 ms = 15秒
です。
なぜ15秒?
Gunicornでは--timeoutを明示していないため、既定値は30秒です。
そこで、
Gemini:15秒
Gunicorn:30秒
とし、
Gunicornの既定timeoutより前にGemini側をtimeoutさせ、fallback処理へ移れる余地を確保しました。
Guest AI回数制限は維持
GuestではAI機能を合計3回まで利用できます。
既存仕様では、
利用権確保
↓
DB commit
↓
Gemini API
という順番です。
そのためGeminiがtimeoutしても、
一度確保した利用回数は戻しません。
外部API失敗によってDB transactionを長時間保持することもありません。
外部AIが遅延・停止しても、アプリ全体まで一緒に長時間待たせないための変更です。
🧪 timeout pytest
確認内容:
- timeout設定がSDKへ実際に渡る
- timeout時に安全なfallback
- Guest timeout時もAI利用回数を戻さない
- 既存429 / 503処理維持
- API keyなしでは未呼び出し
- 売上0件では未呼び出し
- Dataset境界維持
結果:
413 passed
4 skipped
0 failed
Change G:PostgreSQLをGitHub Actionsで毎回確認する
SQLite GREENだけでは分からないことがある
これまでpytestは大量にありましたが、通常のGitHub ActionsではSQLite中心でした。
PostgreSQL専用integration testは、
4 skipped
になります。
しかし本番DBはPostgreSQLです。
例えば、
- advisory lock
SELECT ... FOR UPDATE- atomic UPSERT
- transaction競合
などは、SQLiteだけでは十分に再現できません。
二層CIへ変更
GitHub Actionsを、
SQLite中心の通常test
+
PostgreSQL integration
の二層構成にしました。
既存の高速なtest jobは維持します。
追加したPostgreSQL jobでは、
PostgreSQL 16
をservice containerとして起動。
pg_isreadyでhealth checkしてからテストを開始します。
テストDB破壊処理もfail-closed
PostgreSQL integration testでは、テストごとにDBを初期化する必要があります。
しかし接続先を間違えると危険です。
そこでDB reset処理は、以下をすべて満たさない限り拒否するようにしました。
PostgreSQLである
loopback接続である
DB名が sales_data_app_test
reset許可フラグが1
PostgreSQL integration testにはDBを初期化する処理があります。
そのため、
- PostgreSQL
- loopback接続
sales_data_app_test- reset許可フラグ
の条件をすべて満たさない接続先では、処理自体を拒否するfail-closed設計にしています。
つまり、
テストコード自身が本番DBを破壊しにくい構造
にしています。
Change H:migration・cleanup・DailySales競合を実PostgreSQLで検証
Change GでPostgreSQL CI基盤を作ったので、さらに本番DB特有の挙動を検証しました。
Alembic migration
単に、
空DB
↓
alembic upgrade head
が成功するだけではなく、
Dataset導入前の状態を再現して、
Product
DailySales
を入れた状態からmigrationを進めました。
その結果、
- migration headまで到達
- Product保持
- DailySales保持
- Admin Datasetへ正しく移行
を確認しました。
PostgreSQL migration
1 passed
Guest cleanup競合
Guest cleanupでは、
期限切れ候補取得
↓
row lock
↓
最新状態を再読込
↓
期限を再判定
↓
削除
という流れを使っています。
PostgreSQLで次の2ケースを確認しました。
activityが先
Guest activity更新
↓
commit
↓
cleanup
↓
最新last_activity_atを再確認
↓
削除しない
cleanupが先
cleanupがrow lock取得
↓
期限切れ確認
↓
削除
↓
後発activity update
↓
Datasetを復活させない
Adminや別Guest、その配下データが削除されないことも確認しています。
そして、本当に競合が見つかった
今回、特に大きかったのがDailySalesです。
監査では、
初回売上登録が
SELECT → 存在しなければINSERT
のため、PostgreSQL並行requestで競合する可能性がある
と指摘されていました。
あくまで「可能性」だったので、実際にPostgreSQLで再現させました。
修正前
同じ、
Dataset
Product
date
へ2つのrequestを同時に送信。
結果:
HTTP 200
HTTP 500
となりました。
DB上の最終行数は1件。
500側では、
IntegrityError
が発生しました。
transaction自体はrollbackされ、DB破損はありません。
しかし利用者から見れば、
通常の売上入力で500エラーになる可能性がある
状態です。
SQLite中心の通常pytestでは見えていなかった競合が、実PostgreSQLでの並行テストによって実際に再現しました。
なぜrow lockではなくUPSERT?
最初に考えられるのはrow lockです。
しかし初回登録では、
まだDailySales rowが存在しない
ため、その行を直接ロックできません。
Product rowまでロックすれば直列化できますが、ロック範囲が広がります。
IntegrityError後にretryする方法もありますが、複数商品の売上を同一transactionで保存しているため、
transaction全体の再実行
が必要になります。
そこで既存の、
UNIQUE(product_id, date)
をそのまま利用できる、
原子的UPSERT
を採用しました。
修正後
同じ並行requestを再実行。
HTTP 200
HTTP 200
になりました。
結果:
DailySales row:1件
例外:なし
transaction:両方正常終了
数量については既存仕様どおり、
last-writer-wins
です。
sales_data_appの日次売上入力は、
「現在の累計値を上書きする」
仕様なので、この挙動を維持しています。
🧪 PostgreSQL integration結果
migration:
1 passed
PostgreSQL並行処理:
7 passed
内訳:
- 既存PostgreSQL integration 4件
- cleanup concurrency 2件
- DailySales concurrency 1件
その他:
SQLite売上関連
31 passed
PostgreSQL接続安全ガード
6 passed
通常suite:
419 passed
8 skipped
0 failed
8 skipはPostgreSQL専用testです。
通常suiteでは意図的にskipし、PostgreSQL job側で実行します。
最終的なテスト構成
今回の改善で、pytestの考え方も少し変わりました。
以前は、
SQLiteで大量のpytest
が中心でした。
現在は、
SQLite
↓
高速に広範囲を確認
PostgreSQL
↓
本番DB固有のlock・transaction・migration・並行処理を確認
という二層構成です。
最終結果
今回のChange A〜H終了時点では、
419 passed
8 skipped
0 failed
です。
PostgreSQL専用では、
migration
1 passed
並行処理
7 passed
を確認しました。
git diff --checkも問題ありません。
大規模リファクタリングはしなかった
監査ではapp.pyの肥大化も指摘されました。
ただし今回は、
Application Factory化
Repository Pattern導入
Service層全面導入
既存route全面Blueprint化
といった大規模変更は行っていません。
現在の規模では、
改善できるから全部作り直す
より、
実際に困り始めた場所だけ分離する
方が適切だと判断しました。
今後追加する新機能については、既存app.pyへ何でも追加し続けず、新しい機能から小さなBlueprintとして分離する予定です。
今回の目的は「理想的な構成へ全面改修すること」ではなく、今あるリスクを減らし、次の機能追加へ安全に進める状態にすることです。
次に追加したい機能
Guest Demoの防御開発は、ここで一区切りにします。
今後はsales_data_app本来の、
店舗業務を便利にする機能
へ戻ります。
現在考えているのは以下です。
材料発注
Google Tasksや買い物リストのように、
□ 強力粉
□ バター
□ 牛乳
□ 卵
と、今日注文・購入するものを簡単に管理する機能。
高機能な在庫管理ERPではなく、
忙しい店舗ですぐ使えること
を優先します。
客層入力
日次売上入力時に、
10代
20〜30代
40〜50代
60代以上
ファミリー
一人客
などの簡単な集計データを入力できるようにする予定です。
個人を特定する情報は保存しません。
Dashboard分析
将来的には、
売上
+
曜日
+
平日 / 休日
+
客層
を組み合わせ、
単純な売上ランキングだけでは見えない傾向を分析できるようにしたいと考えています。
AI助言
現在のGemini助言も、
「この商品が1位です」
だけではなく、
「休日のファミリー客が多い日に、
この商品の販売数も伸びています」
のような背景付きの助言へ発展させたいです。
その場合も、生データをそのまま大量送信せず、アプリ側で事前集計した情報だけを渡す予定です。
おわりに
Guest Demoを公開した時点では、
「やっと終わった」
と思っていました。
しかし改めてコードベース全体を見ると、
- backupファイル
- timezone
- 固定year
- Dashboard期間
- logout
- 外部AI timeout
- PostgreSQL CI
- 並行処理
など、公開して初めて気になった部分が残っていました。
一方で、改善を続ければ終わりがなくなります。
今回は、
今直す価値があるもの
将来必要になったら直すもの
を分けました。
例えば、
- cleanupの
SKIP LOCKED - Redis導入
- PostgreSQL RLS
- Service層全面導入
- 大規模Blueprint化
などは、現在の規模では後回しにしています。
「念のため」で仕組みを増やし続けるのではなく、
今の利用規模と実際の事故リスクに合わせて止めること
も個人開発では大切だと感じました。
