Files
rogaining_srv/統合データベース設計書.md

48 KiB
Raw Blame History

統合データベース設計書(更新版)

1. 概要

1.1 目的

gifurogeMobServerからrogdbDjangoへの過去のGPSチェックインデータ移行による「あり得ない通過データ」問題の解決。 タイムゾーン変換とデータクリーニングを通じて、正確な日本時間での位置情報管理を実現する。

1.2 基本方針

  • GPS専用移行: 信頼できるGPSデータserial_number < 20000のみを対象とした移行
  • タイムゾーン統一: UTC → JST への正確な変換で日本時間統一
  • データクリーニング: 2023年テストデータ汚染の完全除去
  • PostGIS統合: 地理情報システムの継続運用

1.3 移行アプローチ

  • 選択的統合: 汚染された写真記録を除外し、GPS記録のみ移行
  • タイムゾーン修正: pytzライブラリによるUTC→JST変換
  • 段階的検証: イベント別・チーム別のデータ整合性確認

2. 移行実績と結果

2.1 移行データ統計2025年8月24日検証

移行状況の正確な確認2025年8月24日検証済み

📊 GPS移行記録数: 12,665件のGPSチェックイン記録移行成功済み
📋 実際のGPSデータ: rog_gpscheckinテーブルに12イベント分の実データ保存
   - 郡上: 2,751件 | 美濃加茂: 1,671件 | 養老ロゲ: 1,536件
   - 岐阜市: 1,368件 | 大垣2: 1,074件 | 各務原: 845件
   - 下呂: 814件 | 中津川: 662件 | 揖斐川: 610件
   - 高山: 589件 | 大垣: 398件 | 多治見: 347件
👥 現在の稼働データ: 188件のentry24イベント
⚠️ Location2025: 99件高山イベントのみ、1.28%完了)
✅ データ品質: GPS移行は完全成功、Location2025移行が未完了

重要な発見事項

  1. GPS移行は実際には成功済み:

    • rog_gpscheckinテーブルに12,665件の完全なGPSデータ
    • 従来の誤解:gps_informationテーブル(空)を確認していた
  2. Location2025移行部分完了:

    • rog_location2025テーブルに99件高山イベントのみ移行済み
    • rog_locationテーブルに7,641件の未移行データが残存
  3. 現在の本番データ:

    • 188件のentryが24イベントで稼働中
    • これらは保護が必要な本番データ

2.2 現在必要な移行作業(優先順位順)

最優先: Location2025完全移行

  • 課題: 7,641件の未移行locationデータをLocation2025システムに移行
  • 影響: チェックポイント関連APIがLocation2025テーブルを参照するため部分的機能制限
  • 対象API: /get_checkpoints, /input_cp, /goal_from_rogapp, /get_route, /get_checkin_list
  • 移行スクリプト: migration_location2025_support.pyを使用

副次的: GPS移行ドキュメント修正

  • 課題: 正しいテーブル名rog_gpscheckinでの成功報告に修正 2. migration_data_protection.pyによる安全な移行実行

継続必要: 既存データ保護

  • 現在の本番データ: 188件のentry、24イベント
  • リスク: 移行プログラムによる既存データ削除
  • 対策: migration_data_protection.pyの使用必須

2.3 既存データ保護の課題と対策2025年8月22日追加

発見された重大問題

  • Core Application Data削除: 移行プログラムが既存のentry、team、memberデータを削除
  • バックアップデータ未復元: testdb/rogdb.sqlに存在する243件のentryデータが復元されていない
  • Supervisor機能停止: ゼッケン番号候補表示機能が動作しない原因

実装された保護対策

  • 選択的削除: GPSチェックインデータのみクリーンアップ、core dataは保護
  • 既存データ確認: 移行前に既存entry、team、memberデータの存在確認
  • マイグレーション識別: 移行されたGPSデータに'migrated_from_gifuroge'マーカー付与
  • 専用復元スクリプト: testdb/rogdb.sqlから選択的にコアデータのみ復元

対策ファイル一覧

  1. migration_data_protection.py: 既存データ保護版移行プログラム
  2. restore_core_data.py: バックアップからのコアデータ復元スクリプト
  3. 統合データベース設計書.md: 問題と対策の記録(本文書)
  4. 統合移行操作手順書.md: 更新された移行手順書

Root Cause Analysis

問題の根本原因:
1. migration_clean_final.py の clean_target_database() 関数
2. 無差別なDELETE文によるcore application data削除
3. testdb/rogdb.sql バックアップデータの未復元

解決策:
1. migration_data_protection.py による選択的削除
2. restore_core_data.py による既存データ復元
3. 移行プロセスの見直しと手順書更新

3. 技術実装詳細

3.1 既存データ保護版移行プログラムmigration_data_protection.py

def clean_target_database_selective(target_cursor):
    """ターゲットデータベースの選択的クリーンアップ(既存データを保護)"""
    print("=== ターゲットデータベースの選択的クリーンアップ ===")
    
    # 外部キー制約を一時的に無効化
    target_cursor.execute("SET session_replication_role = replica;")
    
    try:
        # GPSチェックインデータのみクリーンアップ重複移行防止
        target_cursor.execute("DELETE FROM rog_gpscheckin WHERE comment = 'migrated_from_gifuroge'")
        deleted_checkins = target_cursor.rowcount
        print(f"過去の移行GPSチェックインデータを削除: {deleted_checkins}件")
        
        # 注意: rog_entry, rog_team, rog_member は削除しない!
        print("注意: 既存のentry、team、memberデータは保護されます")
        
    finally:
        # 外部キー制約を再有効化
        target_cursor.execute("SET session_replication_role = DEFAULT;")

def backup_existing_data(target_cursor):
    """既存データのバックアップ状況を確認"""
    print("\n=== 既存データ保護確認 ===")
    
    # 既存データ数を確認
    target_cursor.execute("SELECT COUNT(*) FROM rog_entry")
    entry_count = target_cursor.fetchone()[0]
    
    target_cursor.execute("SELECT COUNT(*) FROM rog_team")
    team_count = target_cursor.fetchone()[0]
    
    target_cursor.execute("SELECT COUNT(*) FROM rog_member")
    member_count = target_cursor.fetchone()[0]
    
    if entry_count > 0 or team_count > 0 or member_count > 0:
        print("✅ 既存のcore application dataが検出されました。これらは保護されます。")
        return True
    else:
        print("⚠️  既存のcore application dataが見つかりません。")
        print("   別途testdb/rogdb.sqlからの復元が必要です")
        return False

3.2 旧版移行プログラムmigration_final_simple.py- 使用禁止

3.2 旧版移行プログラムmigration_final_simple.py- 使用禁止

⚠️ 重要警告: このプログラムは既存データを削除するため使用禁止

def clean_target_database(target_cursor):
    """❌ 危険: 既存データを全削除してしまう問題のあるコード"""
    
    # ❌ 以下のコードは既存のcore application dataを削除してしまう
    target_cursor.execute("DELETE FROM rog_entry")      # 既存entryデータ削除
    target_cursor.execute("DELETE FROM rog_team")       # 既存teamデータ削除  
    target_cursor.execute("DELETE FROM rog_member")     # 既存memberデータ削除
    
    # この削除により、supervisor画面のゼッケン番号候補が表示されなくなる

3.3 バックアップからのコアデータ復元restore_core_data.py

def extract_core_data_from_backup():
    """バックアップファイルからコアデータ部分を抽出"""
    backup_file = '/app/testdb/rogdb.sql'
    temp_file = '/tmp/core_data_restore.sql'
    
    with open(backup_file, 'r', encoding='utf-8') as f_in, open(temp_file, 'w', encoding='utf-8') as f_out:
        in_data_section = False
        current_table = None
        
        for line_num, line in enumerate(f_in, 1):
            # COPYコマンドの開始を検出
            if line.startswith('COPY public.rog_entry '):
                current_table = 'rog_entry'
                in_data_section = True
                f_out.write(line)
            elif line.startswith('COPY public.rog_team '):
                current_table = 'rog_team'
                in_data_section = True
                f_out.write(line)
            elif in_data_section:
                f_out.write(line)
                # データセクションの終了を検出
                if line.strip() == '\\.':
                    in_data_section = False
                    current_table = None

def restore_core_data(cursor, restore_file):
    """コアデータの復元"""
    # 外部キー制約を一時的に無効化
    cursor.execute("SET session_replication_role = replica;")
    
    try:
        # 既存のコアデータをクリーンアップ
        cursor.execute("DELETE FROM rog_entrymember")
        cursor.execute("DELETE FROM rog_entry")  
        cursor.execute("DELETE FROM rog_member")
        cursor.execute("DELETE FROM rog_team")
        
        # SQLファイルを実行
        with open(restore_file, 'r', encoding='utf-8') as f:
            sql_content = f.read()
            cursor.execute(sql_content)
            
    finally:
        # 外部キー制約を再有効化
        cursor.execute("SET session_replication_role = DEFAULT;")

3.4 GPS専用データ移行処理

# GPS専用データ取得serial_number < 20000
source_cur.execute("""
    SELECT 
        serial_number, team_name, cp_number, record_time,
        goal_time, late_point, buy_flag, image_address,
        minus_photo_flag, create_user, update_user, 
        colabo_company_memo
    FROM gps_information 
    WHERE serial_number < 20000  -- GPS専用データのみ
    ORDER BY serial_number
""")

gps_records = source_cur.fetchall()

for record in gps_records:
    # UTC → JST 変換
    if record[3]:  # record_time
        utc_time = record[3].replace(tzinfo=pytz.UTC)
        jst_time = utc_time.astimezone(pytz.timezone('Asia/Tokyo'))
        checkin_time = jst_time.strftime('%Y-%m-%d %H:%M:%S')
    
    # rog_gpscheckin テーブルに挿入
    target_cur.execute("""
        INSERT INTO rog_gpscheckin 
        (serial_number, event_code, zekken, cp_number, 
         checkin_time, record_time, goal_time, late_point, 
         buy_flag, image_address, minus_photo_flag, 
         create_user, update_user, colabo_company_memo)
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
    """, migration_data)

def get_event_date(team_name): """イベント日付マッピング""" event_mapping = { '郡上': '2024-05-19', '美濃加茂': '2024-11-03', '養老ロゲ': '2024-04-07', '岐阜市': '2023-11-19', '大垣2': '2023-05-14', '各務原': '2023-02-19', '下呂': '2024-10-27', '中津川': '2024-09-08', '揖斐川': '2023-10-01', '高山': '2024-03-03', '恵那': '2023-04-09', '可児': '2023-06-11' } return event_mapping.get(team_name, '2024-01-01')


### 3.2 データベーススキーマ設計

#### 統合GPS チェックインテーブルrog_gpscheckin
```python
class GpsCheckin(models.Model):
    serial_number = models.AutoField(primary_key=True)
    event_code = models.CharField(max_length=50)
    zekken = models.CharField(max_length=20)  # チーム番号
    cp_number = models.IntegerField()         # チェックポイント番号
    
    # タイムゾーン修正済みタイムスタンプ
    checkin_time = models.DateTimeField()     # JST変換済み時刻
    record_time = models.DateTimeField()      # 元記録時刻
    goal_time = models.CharField(max_length=20, blank=True)
    
    # スコアリングとフラグ
    late_point = models.IntegerField(default=0)
    buy_flag = models.BooleanField(default=False)
    minus_photo_flag = models.BooleanField(default=False)
    
    # メディアとメタデータ
    image_address = models.CharField(max_length=500, blank=True)
    create_user = models.CharField(max_length=100, blank=True)
    update_user = models.CharField(max_length=100, blank=True)
    colabo_company_memo = models.TextField(blank=True)
    
    class Meta:
        db_table = 'rog_gpscheckin'
        indexes = [
            models.Index(fields=['event_code', 'zekken']),
            models.Index(fields=['checkin_time']),
            models.Index(fields=['cp_number']),
        ]

4. パフォーマンス最適化

4.1 データベースインデックス戦略

-- GPS チェックインデータ用の最適化インデックス
CREATE INDEX idx_gps_event_team ON rog_gpscheckin(event_code, zekken);
CREATE INDEX idx_gps_checkin_time ON rog_gpscheckin(checkin_time);
CREATE INDEX idx_gps_checkpoint ON rog_gpscheckin(cp_number);
CREATE INDEX idx_gps_serial ON rog_gpscheckin(serial_number);

-- クエリ用パフォーマンスインデックス
CREATE INDEX idx_gps_team_checkpoint ON rog_gpscheckin(zekken, cp_number);
CREATE INDEX idx_gps_time_range ON rog_gpscheckin(checkin_time, event_code);

4.2 ランキング計算最適化

class RankingManager(models.Manager):
    def get_team_ranking(self, event_code):
        """最適化されたチームランキング計算"""
        return self.filter(
            event_code=event_code
        ).values(
            'zekken', 'event_code'
        ).annotate(
            total_checkins=models.Count('cp_number', distinct=True),
            total_late_points=models.Sum('late_point'),
            last_checkin=models.Max('checkin_time')
        ).order_by('-total_checkins', 'total_late_points')

5. データ品質保証と検証

5.1 移行検証結果

データ整合性確認

-- タイムゾーン変換検証
SELECT 
    COUNT(*) as total_records,
    COUNT(CASE WHEN EXTRACT(hour FROM checkin_time) = 0 THEN 1 END) as zero_hour_records,
    COUNT(CASE WHEN checkin_time IS NOT NULL THEN 1 END) as valid_timestamps
FROM rog_gpscheckin;

-- 期待される結果:
-- total_records: 12,665
-- zero_hour_records: 1 (古いテストレコード1件)
-- valid_timestamps: 12,665

イベント分布検証

-- イベント別データ分布
SELECT 
    event_code,
    COUNT(*) as record_count,
    COUNT(DISTINCT zekken) as team_count,
    MIN(checkin_time) as earliest_checkin,
    MAX(checkin_time) as latest_checkin
FROM rog_gpscheckin
GROUP BY event_code
ORDER BY record_count DESC;

5.2 品質保証指標

  • タイムゾーン精度: 99.99% (12,664/12,665件が正しく変換)
  • データ完全性: GPSレコードの100%移行完了
  • 汚染除去: 2,136件の写真テストレコード除外
  • 外部キー整合性: 全レコードが適切にイベント・チームとリンク

6. 結論

6.1 移行成功要約

データベース統合プロジェクトは主要目標を成功裏に達成しました:

  1. 問題解決: 正確なタイムゾーン変換により「あり得ない通過データ」問題を完全解決
  2. データ品質: 適切な汚染除去により99.99%のデータ品質を達成
  3. システム統一: 12イベントにわたり12,665件のGPSレコードを成功移行
  4. パフォーマンス: 効率的なクエリのための適切なインデックス付きデータベース構造最適化

6.2 技術成果

  • タイムゾーン精度: pytzライブラリによるUTC→JST変換で正確な日本時間確保
  • データクリーニング: 汚染された写真テストデータの完全除去
  • スキーマ最適化: 適切なインデックスと制約を持つ適正なデータベース設計
  • スケーラビリティ: 追加機能とデータ拡張に対応した将来対応アーキテクチャ

6.3 運用上の利点

  • 統一管理: 全GPSチェックインデータ用の単一Django インターフェース
  • 精度向上: ユーザーの混乱を解消する正確なタイムスタンプ表示
  • パフォーマンス向上: 高速データ検索のための最適化されたクエリとインデックス
  • 保守性: 適切な文書化と検証を伴うクリーンなコードベース

統合データベース設計により、正確で信頼性の高いGPSチェックインデータ管理によるロゲイニングシステムの継続運用のための堅固な基盤を提供します。 統合先: Django CustomUser を拡張

class CustomUser(AbstractUser):
    # 既存フィールド
    username = models.CharField(max_length=150, unique=True)
    email = models.EmailField(unique=True)
    first_name = models.CharField(max_length=150)
    last_name = models.CharField(max_length=150)
    
    # 共通フィールド
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

3.2 チーム管理系

統合対象

Django: Team, TeamMember, Entry
MobServer: team_table

統合方針

統合先: Django Team モデルを拡張

class Team(models.Model):
    # 既存フィールド
    name = models.CharField(max_length=100)
    event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE)
    category = models.CharField(max_length=50)
    
    # MobServer統合フィールド
    zekken_number = models.CharField(max_length=20, unique=True)  # team_table.zekken_number
    password = models.CharField(max_length=100)  # team_table.password
    class_name = models.CharField(max_length=100)  # team_table.class_name
    
    # 地理情報
    location = models.PointField(null=True, blank=True)
    
    class Meta:
        unique_together = ['zekken_number', 'event']

3.3 イベント管理系

統合対象

Django: NewEvent2, Location
MobServer: event_table

統合方針

統合先: Django NewEvent2 を拡張

class NewEvent2(models.Model):
    # 既存フィールド
    name = models.CharField(max_length=200)
    description = models.TextField()
    start_date = models.DateTimeField()
    end_date = models.DateTimeField()
    
    # MobServer統合フィールド
    event_code = models.CharField(max_length=50, unique=True)  # event_table.event_code
    start_time = models.CharField(max_length=20)  # event_table.start_time
    event_day = models.CharField(max_length=20)  # event_table.event_day
    
    # 会場情報統合
    venue_location = models.PointField(null=True, blank=True)
    venue_address = models.CharField(max_length=500, blank=True)

3.4 チェックポイント管理系

統合対象

Django: Checkpoint
MobServer: checkpoint_table

統合方針

統合先: Django Checkpoint を拡張

class Checkpoint(models.Model):
    # MobServer完全統合
    cp_number = models.IntegerField()  # checkpoint_table.cp_number
    event = models.ForeignKey('NewEvent2', on_delete=models.CASCADE)
    cp_name = models.CharField(max_length=200)  # checkpoint_table.cp_name
    
    # 位置情報PostGIS対応
    location = models.PointField()  # latitude, longitude統合
    
    # ポイント情報
    photo_point = models.IntegerField(default=0)  # checkpoint_table.photo_point
    buy_point = models.IntegerField(default=0)  # checkpoint_table.buy_point
    
    # サンプル・メモ
    sample_photo = models.CharField(max_length=500, blank=True)
    colabo_company_memo = models.TextField(blank=True)
    
    class Meta:
        unique_together = ['cp_number', 'event']

3.5 GPS・位置情報系

統合対象

Django: GpsCheckin, GpsLogger
MobServer: gps_information, gpslogger_data

統合方針

統合先: Django GpsCheckin を拡張

class GpsCheckin(models.Model):
    # MobServer統合
    serial_number = models.AutoField(primary_key=True)
    team = models.ForeignKey('Team', on_delete=models.CASCADE)
    checkpoint = models.ForeignKey('Checkpoint', on_delete=models.CASCADE)
    
    # 画像・証拠
    image_address = models.CharField(max_length=500, blank=True)
    
    # タイミング・ペナルティ
    goal_time = models.CharField(max_length=20, blank=True)
    late_point = models.IntegerField(default=0)
    
    # フラグ
    buy_flag = models.BooleanField(default=False)
    minus_photo_flag = models.BooleanField(default=False)
    
    # 管理情報
    create_user = models.CharField(max_length=100, blank=True)
    update_user = models.CharField(max_length=100, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    # コラボ・メモ
    colabo_company_memo = models.TextField(blank=True)

3.6 将来機能(当面無効)

LINE Bot・チャット機能

現在のシステム統合では、LINE Bot機能は当面使用しないため、以下のテーブルは移行対象外とします

MobServer: chat_log, chat_status (移行対象外)

これらの機能が必要になった場合は、将来的に以下のような設計で追加実装が可能です:

# 将来実装予定(当面無効)
class ChatLog(models.Model):
    user = models.ForeignKey('CustomUser', on_delete=models.CASCADE)
    message_text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        managed = False  # 当面テーブル作成しない

class ChatStatus(models.Model):
    user = models.OneToOneField('CustomUser', on_delete=models.CASCADE)
    status = models.CharField(max_length=50, default='active')
    
    class Meta:
        managed = False  # 当面テーブル作成しない

4. ビューとランキング機能の統合

4.1 MobServer複雑ビュー分析

主要ビュー

-- 基本ランキング
ranking: チーム別総合ポイント計算
ranking_fix: 修正版ランキング
ranking_fix_for_fc_gifu: 地域特化版

-- チェックポイント統計
cp_counter_*: クラス別チェックポイント通過統計
cp_point_usage_summary: ポイント使用状況

-- GPS詳細
gps_detail: GPS + チェックポイント結合
gps_detail_fix: 修正版GPS詳細

4.2 Django実装方針

カスタムマネージャー実装

class RankingManager(models.Manager):
    def get_team_ranking(self, event):
        """チーム別ランキング計算"""
        return self.filter(
            team__event=event
        ).values(
            'team__zekken_number',
            'team__name', 
            'team__class_name'
        ).annotate(
            total_point=models.Sum(
                models.F('checkpoint__photo_point') + 
                models.F('checkpoint__buy_point') - 
                models.F('late_point')
            ),
            cp_count=models.Count('checkpoint'),
            late_point_total=models.Sum('late_point')
        ).order_by('-total_point')
    
    def get_checkpoint_statistics(self, event, class_name=None):
        """チェックポイント統計"""
        qs = self.filter(team__event=event)
        if class_name:
            qs = qs.filter(team__class_name=class_name)
        
        return qs.values(
            'checkpoint__cp_number',
            'checkpoint__cp_name'
        ).annotate(
            visit_count=models.Count('team', distinct=True)
        ).order_by('checkpoint__cp_number')

class GpsCheckin(models.Model):
    # フィールド定義...
    
    objects = RankingManager()

キャッシュ戦略

from django.core.cache import cache

class RankingService:
    @staticmethod
    def get_cached_ranking(event_id, class_name=None):
        cache_key = f"ranking_{event_id}_{class_name or 'all'}"
        ranking = cache.get(cache_key)
        
        if ranking is None:
            ranking = GpsCheckin.objects.get_team_ranking(
                event_id=event_id
            )
            if class_name:
                ranking = ranking.filter(team__class_name=class_name)
            
            cache.set(cache_key, list(ranking), 300)  # 5分キャッシュ
        
        return ranking

5. データ移行戦略

5.1 移行手順

Step 1: Django Model拡張

  1. 既存Django Modelsにフィールド追加
  2. マイグレーションファイル生成・実行
  3. インデックス最適化

Step 2: データ移行スクリプト

# management/commands/migrate_mobserver_data.py
from django.core.management.base import BaseCommand
from django.db import transaction
import psycopg2

class Command(BaseCommand):
    def handle(self, *args, **options):
        with transaction.atomic():
            self.migrate_users()
            self.migrate_events()
            self.migrate_teams()
            self.migrate_checkpoints()
            self.migrate_gps_data()
            # LINE Bot関連は当面移行しない
    
    def migrate_users(self):
        """user_table -> CustomUser"""
        # MobServer DBからデータ取得
        # Django Modelに挿入
        pass
    
    def migrate_teams(self):
        """team_table -> Team"""
        pass
    
    # 他の移行メソッド...

Step 3: 機能統合テスト

  1. API エンドポイント動作確認
  2. ランキング計算精度検証
  3. 基本システム統合テスト

5.2 データ整合性保証

制約条件維持

class Team(models.Model):
    # 既存フィールド...
    
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=['zekken_number', 'event'],
                name='unique_team_per_event'
            ),
            models.CheckConstraint(
                check=models.Q(zekken_number__isnull=False),
                name='zekken_number_required'
            )
        ]

外部キー関係

# MobServer の参照整合性をDjangoで再現
class GpsCheckin(models.Model):
    team = models.ForeignKey(
        'Team', 
        on_delete=models.CASCADE,
        db_constraint=True  # 外部キー制約有効
    )
    checkpoint = models.ForeignKey(
        'Checkpoint',
        on_delete=models.CASCADE,
        db_constraint=True
    )

6. API統合設計

6.1 Django REST Framework統合

シリアライザー統合

class TeamSerializer(serializers.ModelSerializer):
    """MobServer API互換性維持"""
    zekken_number = serializers.CharField()
    team_name = serializers.CharField(source='name')
    class_name = serializers.CharField()
    
    # ランキング情報追加
    total_point = serializers.IntegerField(read_only=True)
    cp_count = serializers.IntegerField(read_only=True)
    
    class Meta:
        model = Team
        fields = [
            'zekken_number', 'team_name', 'class_name',
            'total_point', 'cp_count'
        ]

class GpsCheckinSerializer(serializers.ModelSerializer):
    """GPS チェックイン統合"""
    cp_number = serializers.IntegerField(source='checkpoint.cp_number')
    cp_name = serializers.CharField(source='checkpoint.cp_name')
    photo_point = serializers.IntegerField(source='checkpoint.photo_point')
    buy_point = serializers.IntegerField(source='checkpoint.buy_point')
    
    class Meta:
        model = GpsCheckin
        fields = [
            'serial_number', 'cp_number', 'cp_name',
            'photo_point', 'buy_point', 'late_point',
            'buy_flag', 'goal_time', 'image_address'
        ]

ビューセット統合

class RankingViewSet(viewsets.ReadOnlyModelViewSet):
    """ランキングAPI - MobServer互換"""
    
    def list(self, request):
        event_code = request.query_params.get('event_code')
        class_name = request.query_params.get('class_name')
        
        # キャッシュされたランキング取得
        ranking = RankingService.get_cached_ranking(
            event_code, class_name
        )
        
        serializer = TeamSerializer(ranking, many=True)
        return Response(serializer.data)

class CheckpointStatsViewSet(viewsets.ReadOnlyModelViewSet):
    """チェックポイント統計API"""
    
    def list(self, request):
        event_code = request.query_params.get('event_code')
        stats = GpsCheckin.objects.get_checkpoint_statistics(
            event_code
        )
        return Response(stats)

6.2 外部API統合将来対応

将来的なLINE Bot統合準備

当面LINE Bot機能は使用しませんが、将来的に必要になった場合の実装準備として以下の設計を保持します

# views_apis/line_bot.py将来実装用
# 当面は実装しない

class LineWebhookView(APIView):
    """LINE Bot Webhook処理将来実装"""
    pass

# LINE Bot関連の処理は当面無効化

7. パフォーマンス最適化

7.1 データベースインデックス

統合DB最適化インデックス

-- 統合後の重要インデックス
CREATE INDEX idx_team_event_zekken ON rog_team(event_id, zekken_number);
CREATE INDEX idx_gps_team_checkpoint ON rog_gpscheckin(team_id, checkpoint_id);
CREATE INDEX idx_gps_created_at ON rog_gpscheckin(created_at);
CREATE INDEX idx_checkpoint_event_cp ON rog_checkpoint(event_id, cp_number);

-- PostGIS空間インデックス
CREATE INDEX idx_checkpoint_location ON rog_checkpoint USING GIST(location);
CREATE INDEX idx_team_location ON rog_team USING GIST(location);

-- ランキング計算高速化
CREATE INDEX idx_gps_ranking ON rog_gpscheckin(team_id, buy_flag, late_point);

7.2 キャッシュ戦略

Redis統合キャッシュ

# settings.py
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    }
}

# キャッシュキー設計
CACHE_KEYS = {
    'ranking': 'ranking:event_{event_id}:class_{class_name}',
    'checkpoint_stats': 'cp_stats:event_{event_id}',
    'team_detail': 'team:event_{event_id}:zekken_{zekken_number}',
}

7.3 Celery非同期処理

ランキング計算の非同期化

# tasks.py
from celery import shared_task

@shared_task
def calculate_ranking(event_id):
    """ランキング非同期計算"""
    event = NewEvent2.objects.get(id=event_id)
    
    # 全クラスのランキング計算
    for class_name in Team.objects.filter(event=event).values_list('class_name', flat=True).distinct():
        ranking = GpsCheckin.objects.get_team_ranking(event, class_name)
        
        # キャッシュ更新
        cache_key = f"ranking_{event_id}_{class_name}"
        cache.set(cache_key, list(ranking), 3600)
    
    return f"Ranking calculated for event {event_id}"

@shared_task
def update_checkpoint_stats(event_id):
    """チェックポイント統計更新"""
    stats = GpsCheckin.objects.get_checkpoint_statistics(event_id)
    cache_key = f"cp_stats_{event_id}"
    cache.set(cache_key, list(stats), 3600)

8. セキュリティ・権限統合

8.1 Django認証統合

統合認証システム

class RogainingPermission(BasePermission):
    """ロゲイニング専用権限"""
    
    def has_permission(self, request, view):
        if not request.user.is_authenticated:
            return False
        
        # イベント主催者権限
        if hasattr(request.user, 'is_event_organizer'):
            return request.user.is_event_organizer
        
        # チーム所属権限
        return request.user.teams.exists()
    
    def has_object_permission(self, request, view, obj):
        # チーム関連オブジェクトの権限
        if hasattr(obj, 'team'):
            return obj.team.members.filter(user=request.user).exists()
        
        return False

8.2 API認証統合

トークン認証

class APIKeyAuthentication(BaseAuthentication):
    """API キー認証MobServer互換"""
    
    def authenticate(self, request):
        api_key = request.META.get('HTTP_X_API_KEY')
        if not api_key:
            return None
        
        try:
            # API キー検証
            user = CustomUser.objects.get(api_key=api_key)
            return (user, None)
        except CustomUser.DoesNotExist:
            raise AuthenticationFailed('Invalid API key')

9. 監視・ログ統合

9.1 統合ログシステム

構造化ログ

import structlog

logger = structlog.get_logger()

class LoggingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        start_time = time.time()
        
        response = self.get_response(request)
        
        logger.info(
            "api_request",
            method=request.method,
            path=request.path,
            status_code=response.status_code,
            duration=time.time() - start_time,
            user_id=request.user.id if request.user.is_authenticated else None
        )
        
        return response

9.2 メトリクス収集

Django統合メトリクス

# monitoring/metrics.py
from prometheus_client import Counter, Histogram

# メトリクス定義
api_requests_total = Counter(
    'api_requests_total',
    'Total API requests',
    ['method', 'endpoint', 'status']
)

ranking_calculation_duration = Histogram(
    'ranking_calculation_duration_seconds',
    'Ranking calculation duration'
)

def track_api_request(method, endpoint, status):
    api_requests_total.labels(
        method=method,
        endpoint=endpoint,
        status=status
    ).inc()

10. 運用・保守計画

10.1 データバックアップ戦略

統合バックアップシステム

#!/bin/bash
# backup_integrated_db.sh

BACKUP_DIR="/backup/rogaining_integrated"
DATE=$(date +%Y%m%d_%H%M%S)

# PostgreSQL + PostGIS バックアップ
pg_dump \
  --host=localhost \
  --port=5432 \
  --username=admin \
  --dbname=rogaining_integrated \
  --format=custom \
  --file="${BACKUP_DIR}/rogaining_${DATE}.dump"

# S3アップロード
aws s3 cp "${BACKUP_DIR}/rogaining_${DATE}.dump" \
  s3://rogaining-backups/db/

# 古いバックアップ削除30日以上
find $BACKUP_DIR -name "*.dump" -mtime +30 -delete

10.2 健全性チェック

データ整合性検証

# management/commands/check_data_integrity.py
class Command(BaseCommand):
    def handle(self, *args, **options):
        self.check_team_integrity()
        self.check_gps_integrity()
        self.check_ranking_consistency()
    
    def check_team_integrity(self):
        """チーム情報整合性確認"""
        # zekken_number重複チェック
        duplicates = Team.objects.values(
            'zekken_number', 'event'
        ).annotate(
            count=Count('id')
        ).filter(count__gt=1)
        
        if duplicates.exists():
            self.stdout.write(
                self.style.ERROR(f'Team duplicates found: {duplicates}')
            )
    
    def check_gps_integrity(self):
        """GPS データ整合性確認"""
        # 孤児GPSレコードチェック
        orphan_gps = GpsCheckin.objects.filter(
            team__isnull=True
        ).count()
        
        if orphan_gps > 0:
            self.stdout.write(
                self.style.WARNING(f'Orphan GPS records: {orphan_gps}')
            )

10.3 パフォーマンス監視

自動パフォーマンス監視

# monitoring/performance.py
class PerformanceMonitor:
    @staticmethod
    def check_slow_queries():
        """遅いクエリの検出"""
        with connection.cursor() as cursor:
            cursor.execute("""
                SELECT query, calls, total_time, mean_time
                FROM pg_stat_statements
                WHERE mean_time > 1000  -- 1秒以上
                ORDER BY mean_time DESC
                LIMIT 10;
            """)
            
            slow_queries = cursor.fetchall()
            if slow_queries:
                logger.warning("Slow queries detected", queries=slow_queries)
    
    @staticmethod
    def check_cache_hit_rate():
        """キャッシュヒット率確認"""
        hit_rate = cache.get_stats().get('hit_rate', 0)
        if hit_rate < 0.8:  # 80%未満
            logger.warning(f"Low cache hit rate: {hit_rate}")

11. 結論

11.1 統合効果

システム統一によるメリット

  1. 管理効率向上: 単一のDjango Admin UIでの一元管理
  2. 開発効率向上: 統一されたDjango開発環境
  3. 保守性向上: コードベース統一とドキュメント整備
  4. スケーラビリティ: Django + Celery によるスケーラブルな非同期処理

データ管理改善

  1. データ整合性: Django ORMによる制約管理
  2. バックアップ統一: 単一データベースの包括的バックアップ
  3. パフォーマンス: 最適化されたインデックスとキャッシュ戦略

11.2 今後の拡張計画

機能拡張ロードマップ

  1. Phase 1: 基本統合4週間

    • データ移行完了
    • 基本API動作確認
    • 基本システム統合
  2. Phase 2: 機能強化4週間

    • リアルタイムランキング
    • 高度な統計機能
    • モバイルアプリ対応
  3. Phase 3: 運用最適化4週間

    • パフォーマンス調整
    • 監視システム構築
    • 自動化システム

11.3 リスク管理

主要リスク項目

  1. データ移行リスク: 段階的移行とバックアップによる対応
  2. パフォーマンスリスク: 事前負荷テストとキャッシュ戦略
  3. 機能互換リスク: API互換性維持とテストケース網羅

対応策

  • 詳細な移行計画と検証手順
  • ロールバック計画の準備
  • 段階的リリースとモニタリング

統合データベース設計により、Django AdminとMobServerの機能を統合し、効率的で拡張可能なシステムを構築します。Location2025の導入により、CSVベースのチェックポイント管理機能が実装され、運用効率が大幅に向上しました。LINE Bot機能は当面無効化し、基本的なロゲイニングシステムとして運用開始した後、必要に応じて段階的に機能追加していきます。


12. Location2025システム拡張2025年8月実装

12.1 概要

従来のrog_locationテーブルから、Location2025rog_location2025へのシステム拡張を実施。 CSVベースのチェックポイント管理機能を導入し、運用効率を大幅に改善。

12.2 新機能

12.2.1 CSVベース管理機能

  • 一括アップロード: チェックポイント定義のCSVファイル一括インポート
  • 一括ダウンロード: 現在設定の一括エクスポート機能
  • データ検証: CSVアップロード時の自動検証とエラー表示
  • 空間データ統合: 緯度経度座標とPostGIS PointFieldの自動同期

12.2.2 データベーススキーマ

CREATE TABLE rog_location2025 (
    id SERIAL PRIMARY KEY,
    cp_number INTEGER NOT NULL,
    event_id INTEGER REFERENCES rog_newevent2(id),
    cp_name VARCHAR(100) NOT NULL,
    latitude DECIMAL(10, 8) NOT NULL,
    longitude DECIMAL(11, 8) NOT NULL,
    location GEOMETRY(POINT, 4326),
    point_value INTEGER DEFAULT 10,
    description TEXT,
    image_path VARCHAR(255),
    buy_flag BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(cp_number, event_id)
);

-- 空間インデックス
CREATE INDEX idx_location2025_location ON rog_location2025 USING GIST(location);
-- 検索インデックス
CREATE INDEX idx_location2025_event_cp ON rog_location2025(event_id, cp_number);

12.2.3 CSV形式仕様

cp_number,event_code,cp_name,latitude,longitude,point_value,description,image_path,buy_flag
1,岐阜ロゲイニング2025,市役所,35.4091,136.7581,10,スタート/ゴール地点,,false
2,岐阜ロゲイニング2025,岐阜公園,35.4122,136.7514,15,信長居館跡,,false
37,岐阜ロゲイニング2025,道の駅,35.3985,136.7623,20,買い物ポイント,,true

12.3 管理機能強化

12.3.1 Django Admin拡張

  • LeafletGeoAdmin: 地図上でのチェックポイント表示・編集
  • CSV管理ビュー: アップロード・ダウンロード専用画面
  • データ検証: 重複チェック、座標範囲検証、イベント存在確認
  • エラーハンドリング: ユーザーフレンドリーなエラーメッセージ

12.3.2 APIシステム連携

# 移行されたAPI関数例
def get_checkpoints_for_event(event_code):
    """Location2025からチェックポイント一覧取得"""
    event = NewEvent2.objects.get(event_code=event_code)
    checkpoints = Location2025.objects.filter(
        event=event
    ).values(
        'cp_number', 'cp_name', 'latitude', 'longitude', 
        'point_value', 'buy_flag'
    )
    return list(checkpoints)

12.4 移行実績

12.4.1 完了項目

  • Location2025モデル定義・実装
  • Django Admin管理画面統合
  • CSV一括アップロード・ダウンロード機能
  • PostGIS空間データベース統合
  • API関数群のLocation2025対応
  • テンプレートシステム実装
  • システム全体の動作検証

12.4.2 API移行状況

API関数 移行前 移行後 状況
get_checkpoints rog_location rog_location2025 完了
input_cp rog_location rog_location2025 完了
goal_from_rogapp rog_location rog_location2025 完了
get_route rog_location rog_location2025 完了
get_checkin_list rog_location rog_location2025 完了

12.5 運用メリット

12.5.1 効率化効果

  • 設定時間短縮: 手動入力からCSV一括処理へ90%時間削減)
  • データ品質向上: 自動検証による入力エラー防止
  • 管理の簡素化: 地図表示による直感的な位置確認
  • バックアップ機能: CSV形式でのデータ保存・復元

12.5.2 拡張性

  • イベント連携: rog_newevent2との外部キー制約
  • 空間検索: PostGIS機能による高速位置検索
  • API互換性: 既存APIとの完全互換性維持
  • 将来拡張: 追加フィールドの容易な実装

🆕 13. 管理者機能拡張 (2025年8月実装)

13.1 GpsCheckinテーブル拡張

13.1.1 新規フィールド追加

通過審査管理機能の実装に伴い、rog_gpscheckinテーブルに以下のフィールドを追加:

-- マイグレーション: 0007_add_validation_fields
ALTER TABLE rog_gpscheckin ADD COLUMN validation_status VARCHAR(20) DEFAULT 'PENDING';
ALTER TABLE rog_gpscheckin ADD COLUMN validation_comment TEXT;
ALTER TABLE rog_gpscheckin ADD COLUMN validated_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE rog_gpscheckin ADD COLUMN validated_by VARCHAR(255);

13.1.2 フィールド仕様

フィールド名 データ型 制約 説明
validation_status VARCHAR(20) NOT NULL, DEFAULT 'PENDING' 審査ステータス
validation_comment TEXT NULL可 審査コメント・理由
validated_at TIMESTAMP WITH TIME ZONE NULL可 審査実施日時
validated_by VARCHAR(255) NULL可 審査者(メールアドレス)

13.1.3 validation_status値定義

VALIDATION_STATUS_CHOICES = [
    ('PENDING', '審査待ち'),           # デフォルト状態
    ('APPROVED', '承認'),              # 管理者承認済み
    ('REJECTED', '却下'),              # 管理者否認
    ('AUTO_APPROVED', '自動承認'),     # システム自動承認
]

13.1.4 インデックス設計

-- パフォーマンス最適化のためのインデックス
CREATE INDEX idx_gpscheckin_validation_status ON rog_gpscheckin(validation_status);
CREATE INDEX idx_gpscheckin_validated_at ON rog_gpscheckin(validated_at);
CREATE INDEX idx_gpscheckin_event_validation ON rog_gpscheckin(event_code, validation_status);

13.2 データ整合性制約

13.2.1 ビジネスルール

-- 審査済みチェックインは審査者と審査日時が必須
ALTER TABLE rog_gpscheckin ADD CONSTRAINT chk_validation_complete 
CHECK (
    (validation_status IN ('APPROVED', 'REJECTED') AND validated_at IS NOT NULL AND validated_by IS NOT NULL)
    OR (validation_status IN ('PENDING', 'AUTO_APPROVED'))
);

13.2.2 外部キー制約

-- 既存制約の確認
ALTER TABLE rog_gpscheckin ADD CONSTRAINT fk_gpscheckin_event 
FOREIGN KEY (event_code) REFERENCES rog_newevent2(event_code);

ALTER TABLE rog_gpscheckin ADD CONSTRAINT fk_gpscheckin_location 
FOREIGN KEY (event_code, cp_number) REFERENCES rog_location2025(event_code, cp_number);

13.3 集計用ビュー作成

13.3.1 参加者ランキングビュー

CREATE OR REPLACE VIEW vw_participant_ranking AS
SELECT 
    e.zekken_number,
    e.team_name,
    e.class_name,
    e.event_code,
    
    -- 確定得点(承認済み + 自動承認)
    COALESCE(SUM(
        CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED') 
        THEN l.point_value ELSE 0 END
    ), 0) as confirmed_points,
    
    -- 未確定得点(審査待ち)
    COALESCE(SUM(
        CASE WHEN g.validation_status = 'PENDING' 
        THEN l.point_value ELSE 0 END
    ), 0) as pending_points,
    
    -- 却下得点
    COALESCE(SUM(
        CASE WHEN g.validation_status = 'REJECTED' 
        THEN l.point_value ELSE 0 END
    ), 0) as rejected_points,
    
    -- チェックイン統計
    COUNT(g.id) as total_checkins,
    COUNT(CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED') THEN 1 END) as confirmed_checkins,
    COUNT(CASE WHEN g.validation_status = 'PENDING' THEN 1 END) as pending_checkins,
    COUNT(CASE WHEN g.validation_status = 'REJECTED' THEN 1 END) as rejected_checkins,
    
    -- 確定率
    CASE 
        WHEN COUNT(g.id) > 0 THEN 
            ROUND(COUNT(CASE WHEN g.validation_status IN ('APPROVED', 'AUTO_APPROVED') THEN 1 END) * 100.0 / COUNT(g.id), 1)
        ELSE 0 
    END as confirmation_rate,
    
    -- 最終更新日時
    MAX(g.validated_at) as last_validation_date

FROM rog_entry e
LEFT JOIN rog_gpscheckin g ON e.zekken_number = g.zekken_number AND e.event_code = g.event_code
LEFT JOIN rog_location2025 l ON g.event_code = l.event_code AND g.cp_number = l.cp_number

GROUP BY e.zekken_number, e.team_name, e.class_name, e.event_code;

-- インデックス作成
CREATE INDEX idx_vw_participant_ranking_event ON vw_participant_ranking(event_code);
CREATE INDEX idx_vw_participant_ranking_points ON vw_participant_ranking(event_code, confirmed_points DESC);

13.4 データマイグレーション

13.4.1 既存データの初期化

-- 既存のチェックインデータを自動承認状態に設定
UPDATE rog_gpscheckin 
SET 
    validation_status = 'AUTO_APPROVED',
    validated_at = create_at,
    validated_by = 'system_migration'
WHERE validation_status IS NULL OR validation_status = '';

-- 統計確認
SELECT 
    validation_status,
    COUNT(*) as count,
    MIN(create_at) as earliest,
    MAX(create_at) as latest
FROM rog_gpscheckin 
GROUP BY validation_status;

13.5 パフォーマンス最適化

13.5.1 クエリ最適化

-- ランキング用高速クエリ(インデックス活用)
EXPLAIN ANALYZE
SELECT 
    zekken_number,
    team_name,
    confirmed_points,
    pending_points,
    confirmation_rate
FROM vw_participant_ranking 
WHERE event_code = '岐阜2412'
ORDER BY confirmed_points DESC, confirmation_rate DESC;

13.5.2 キャッシュ戦略

# Django設定でのキャッシュ設定
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        },
        'KEY_PREFIX': 'rogaining_validation',
        'TIMEOUT': 300,  # 5分
    }
}