Files
rogaining_srv/詳細機能設計書-外部連携.md

25 KiB
Raw Blame History

詳細機能設計書 - 外部システム連携

1. 概要

本文書は、ロゲイニング大会管理システムの外部システム連携機能について詳細に記述したものです。

システム構成

  • Django RESTフレームワーク: メインAPIPython
  • Ruby Sinatra Server: 外部システム連携サーバーMobServer_gifuroge.rb
  • AWS S3: ファイルストレージ
  • 外部ロゲイニングシステム: チーム登録情報との連携

2. 外部連携アーキテクチャ

Django API → Ruby Server → External Rogaining System
              ↓
           AWS S3 Storage

2.1 通信フロー

  1. Django APIが外部システム連携要求を受信
  2. Ruby Sinatraサーバーにリクエスト転送
  3. Ruby Serverから外部ロゲイニングシステムに情報送信
  4. 処理結果をS3にアップロード
  5. レスポンスをDjango API経由でクライアントに返却

3. 外部連携API仕様

3.1 チーム登録API

POST /register_team

目的: 外部ロゲイニングシステムにチーム情報を登録する

リクエスト仕様:

POST /register_team
Content-Type: application/json

{
  "zekken_number": "string",    # ゼッケン番号
  "team_name": "string",        # チーム名
  "event_code": "string",       # イベントコード
  "class_name": "string",       # 参加クラス
  "member_count": "integer",    # メンバー数
  "password": "string"          # チーム認証パスワード
}

レスポンス仕様:

{
  "status": "OK|ERROR",
  "message": "処理結果メッセージ",
  "team_id": "登録されたチームID",
  "timestamp": "2024-01-01T12:00:00Z"
}

処理フロー:

  1. リクエストパラメータの検証
  2. team_tableからチーム情報の取得
  3. 外部システムへの登録データ送信
  4. 登録結果の確認とレスポンス返却

コード実装詳細

# MobServer_gifuroge.rbから抜粋
app.post '/register_team' do
  crossdomain
  headjson
  
  # パラメータ取得
  zekken_number = params[:zekken_number]
  team_name = params[:team_name]
  event_code = params[:event_code]
  
  # チーム情報の検証
  team_data = getTeamDataByZekken_number(zekken_number, event_code)
  
  if team_data['result'] == "ERROR"
    return {
      status: "ERROR",
      message: "チーム情報が見つかりません"
    }.to_json
  end
  
  # 外部システムへの登録処理
  result = register_to_external_system(team_data)
  
  # 結果の返却
  {
    status: result[:status],
    message: result[:message],
    team_id: result[:team_id],
    timestamp: DateTime.now.iso8601
  }.to_json
end

3.2 チーム名更新API

POST /update_team_name

目的: 外部システムに登録済みのチーム名を更新する

リクエスト仕様:

POST /update_team_name
Content-Type: application/json

{
  "zekken_number": "string",    # ゼッケン番号
  "new_team_name": "string",    # 新しいチーム名
  "event_code": "string"        # イベントコード
}

レスポンス仕様:

{
  "status": "OK|ERROR",
  "message": "更新結果メッセージ",
  "old_name": "変更前のチーム名",
  "new_name": "変更後のチーム名",
  "timestamp": "2024-01-01T12:00:00Z"
}

4. スコアボード生成・配信システム

4.1 スコアボード自動生成

機能概要

  • チーム毎のスコアボードを自動生成
  • ExcelファイルからPDFに変換
  • AWS S3への自動アップロード

POST /generate_scoreboard

処理フロー:

  1. GPS情報とチェックポイント通過記録の取得
  2. Excel形式でのスコアボード生成
  3. PDF変換処理
  4. S3へのアップロード
  5. 外部システムへの配信URL通知
def makeScoreboard(zekken_number, event_code, reprintF = false, budleF = false)
  # スコアボード生成処理
  filepath = getScoreboardGeneral(zekken_number, event_code)
  
  if filepath == "no_data"
    return "no data error"
  end
  
  # PDF変換
  command = "/home/mobilousInstance/jodconverter-cli-4.4.5-SNAPSHOT/bin/jodconverter-cli -o #{docpath_user}/#{pdffile}.xlsx #{docpath_user}/#{pdffile}.pdf"
  system(command)
  
  # S3アップロード
  aws_url = s3Uploader("#{event_code}/scoreboard", docpath_user, pdffile + '.pdf')
  
  # レポート情報の更新
  if getFinalReport(zekken_number, event_code) == "no report"
    inputFinalReport(zekken_number, event_code, aws_url)
  else
    changeFinalReport(zekken_number, event_code, aws_url)
  end
  
  aws_url
end

4.2 非同期処理による大量生成

# キューイング処理
$queue = Queue.new

Thread.new do
  loop do
    begin
      item = JSON.parse($queue.pop)
      makeScoreboard(item["zekken_number"], item["event_code"], to_boolean(item["reprintF"]), false)
      result = removeQueueMemory()
    rescue => e
      # エラーログ記録
      timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
      error_message = "#{timestamp}=> #{item} : #{e.message}\n"
      File.open(queue_error_log_base(), "a") do |file|
        file.write(error_message)
      end
    end
  end
end

5. AWS S3連携

5.1 設定情報

def s3_key
  return "AKIA6LVMTADSVEB5LZ2H"
end

def s3_Skey
  return "KIbm47dqVBxSmeHygrh5ENV1uXzJMc7fLnJOvtUm"
end

def s3_bucket
  return "sumasenrogaining"
end

def s3_region
  return "us-west-2"
end

def s3_domain
  return "https://sumasenrogaining.s3.us-west-2.amazonaws.com"
end

5.2 ファイルアップロード機能

def s3Uploader(data_dir, filepath, filename)
  bucket = s3_bucket().freeze
  region = s3_region().freeze
  access_key = s3_key().freeze
  secret_key = s3_Skey().freeze

  aws_storage = S3_StorageUtil.new(access_key, secret_key, region, bucket, data_dir)
  aws_bucket = S3_FolderUtil.new("offlineRecog/work", aws_storage)

  originPath = filepath
  destPath = data_dir
  filename = filename

  aws_bucket.uploadFile(originPath, destPath, filename)

  aws_url = s3_domain() + '/' + data_dir + '/' + filename

  return aws_url
end

6. データベース連携

6.1 PostgreSQL接続設定

def set_dbname
  return "gifuroge"
end

# データベース接続
@pgconn = UserPostgres.new
dbname = set_dbname()
@pgconn.connectPg("localhost", "mobilous", 0, dbname)

6.2 主要テーブル操作

team_table操作

def getTeamDataByZekken_number(zekken_number, event_code)
  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)

  anytable = UserPostgresTable.new
  anytable.useTable(@pgconn, 'team_table')

  where = "zekken_number = '#{zekken_number}' AND event_code = '#{event_code}'"
  list = anytable.find2(where)
  
  result = {}
  list.each { |rec|
    result["zekken_number"] = rec["zekken_number"]
    result["team_name"] = rec["team_name"]
    result["class_name"] = rec["class_name"]
  }

  @pgconn.disconnect
  
  if result != {} && result != nil
    result["result"] = "OK"
    return result
  else
    result["result"] = "ERROR"
    return result
  end
end

gps_information操作

def getGpsInfo(zekken_number, event_code)
  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)

  anytable = UserPostgresTable.new
  anytable.useTable(@pgconn, 'gps_information')

  where = "zekken_number = '#{zekken_number}' AND event_code = '#{event_code}' ORDER BY serial_number"
  list = anytable.find2(where)
  
  result = []
  count = 0

  list.each { |rec|
    result[count] = {"cp_number" => rec['cp_number']}
    count += 1
  }

  @pgconn.disconnect

  return result
end

7. 画像処理・サムネイル生成

7.1 画像リサイズ処理

def image_size_width
  return 150
end

def image_size_height
  return 105
end

# 画像処理実装
filename = rec["cp#{i}_ph"].split('/').last
fullpath = docpath + '/' + filename

# S3からの画像取得
fullpath2 = s3_domain + '/' + URI.encode_www_form_component(event_code, enc=nil) + '/' + zekken_number + '/' + filename
URI.open(fullpath2) do |image|
  File.open(fullpath, 'w') do |file|
    file.write(image.read)
  end
end

# サムネイル生成
extension = filename.split('.').last
filenameTh = filename.gsub(".#{extension}", "_th.#{extension}")

imageOri = Magick::Image.read(fullpath).first
imageTh = imageOri.scale(image_size_width(), image_size_height())
imageTh.write("temp/#{filenameTh}")

nextpath = docpath + '/' + filenameTh
FileUtils.mv("/var/www/temp/#{filenameTh}", nextpath)

result["cp#{i}_ph"] = nextpath

8. エラーハンドリング

8.1 エラーコード体系

# エラーコード一覧
# STA-001 : パスワード入力のキャンセルに失敗した状態
# STA-002 : 処理には成功したがchat_statusのレコードデリートに失敗した状態
# STA-003 : 写真受付のキャンセルに失敗した状態
# USR-001 : LINEのユーザー名を取得できていない状態
# USR-002 : ログインに成功したが、user_tableのレコードアップデートに失敗した状態
# USR-003 : ログアウトの処理に失敗した状態
# DBS-001 : そのユーザーが何時間部門のユーザーなのかを認識できなくなった状態

def text_importantError(errorCode)
  return "エラーコード:#{errorCode}\nゼッケン番号とエラーコードを添えて、運営に問い合わせて下さい。"
end

8.2 例外処理パターン

begin
  ret = anytable.exec(sql)
rescue => error
  p error
  return "UPDATE ERROR"
end

# データベース接続の確実な切断
@pgconn.disconnect

# キューエラーハンドリング
rescue => e
  timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
  item_value = defined?(item) ? item.to_s : ""
  error_message = "#{timestamp}=> #{item_value} : #{e.message}\n"
  File.open(queue_error_log_base(), "a") do |file|
    file.write(error_message)
  end
end

9. セキュリティ対策

9.1 認証・認可

チーム認証

def zekkenAuthorization(zekken, password)
  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)

  anytable = UserPostgresTable.new
  anytable.useTable(@pgconn, 'team_table')

  where = "zekken_number = '#{zekken}' AND password = '#{password}'"
  list = anytable.find2(where)
  
  result = {}
  if list == nil
    return { "zekken_number" => "ERROR" }
  end

  list.each { |rec|
    result["zekken_number"] = rec["zekken_number"]
    result["team_name"] = rec["team_name"]
    result["event_code"] = rec["event_code"]
  }

  @pgconn.disconnect

  if result != {} && result != nil
    result["result"] = "OK"
    return result
  else
    result["result"] = "ERROR"
    return result
  end
end

代理人認証

def agentAuthorization(password)
  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)

  anytable = UserPostgresTable.new
  anytable.useTable(@pgconn, 'agent_table')

  where = "password = '#{password}'"
  list = anytable.find2(where)
  
  result = ""
  if list == nil
    return "ERROR"
  end

  list.each { |rec|
    result = rec["agent_id"]
  }

  @pgconn.disconnect

  if result != "" && result != nil
    return result
  else
    return "ERROR"
  end
end

9.2 SQLインジェクション対策

# パラメータのエスケープ処理
if team_name.include?("'")
  team_name.gsub!("'", "''")
end

where = "team_name = '#{team_name}' AND event_code = '#{event_code}'"

9.3 CORS設定

def crossdomain
  headers 'Access-Control-Allow-Origin' => '*'
end

def headjson
  headers 'Content-Type' => 'application/json; charset=utf-8'
end

10. ログ・監視

10.1 チャットログ

def chatLogger(userId, talker, type, detail)
  p "#{talker} : #{detail}"

  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)

  anytable = UserPostgresTable.new
  anytable.useTable(@pgconn, 'chat_log')

  title = ["userid", "talker", "message_type", "message_detail", "create_at"]
  record = {"userid" => userId, "talker" => talker, "message_type" => type, "message_detail" => detail, "create_at" => DateTime.now}

  sql = anytable.makeInsertKeySet(title, record)
  begin
    ret = anytable.exec(sql)
  rescue => error
    p error
  end

  @pgconn.disconnect

  return "OK"
end

10.2 キューエラーログ

def queue_error_log_base()
  return "/var/log/queue_error.log"
end

# エラーログ記録
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
error_message = "#{timestamp}=> #{item} : #{e.message}\n"
File.open(queue_error_log_base(), "a") do |file|
  file.write(error_message)
end

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

11.1 データベース接続管理

# 接続の確実な切断
@pgconn.disconnect

# 接続プールの活用
def with_db_connection
  @pgconn = UserPostgres.new
  dbname = set_dbname()
  @pgconn.connectPg("localhost", "mobilous", 0, dbname)
  
  begin
    yield @pgconn
  ensure
    @pgconn.disconnect
  end
end

11.2 画像処理の最適化

# サムネイル生成のバッチ処理
def batch_thumbnail_generation(image_list)
  image_list.each do |image_path|
    imageOri = Magick::Image.read(image_path).first
    imageTh = imageOri.scale(image_size_width(), image_size_height())
    imageTh.write(thumbnail_path(image_path))
  end
end

11.3 キューイング最適化

# 非同期処理による負荷分散
Thread.new do
  loop do
    begin
      item = JSON.parse($queue.pop)
      process_queue_item(item)
    rescue => e
      log_queue_error(e, item)
    end
  end
end

12. 運用・保守

12.1 バックアップ戦略

# データベースバックアップ
def backup_database(event_code)
  timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
  backup_file = "backup_#{event_code}_#{timestamp}.sql"
  
  system("pg_dump #{set_dbname()} > #{backup_file}")
  
  # S3へのバックアップアップロード
  s3Uploader("backups", ".", backup_file)
end

12.2 ヘルスチェック

app.get '/health' do
  crossdomain
  headjson
  
  {
    status: "OK",
    timestamp: DateTime.now.iso8601,
    version: "1.0.0",
    database: check_database_connection(),
    s3: check_s3_connection()
  }.to_json
end

def check_database_connection()
  begin
    @pgconn = UserPostgres.new
    dbname = set_dbname()
    @pgconn.connectPg("localhost", "mobilous", 0, dbname)
    @pgconn.disconnect
    return "OK"
  rescue => e
    return "ERROR: #{e.message}"
  end
end

13. 今後の拡張計画

13.1 マイクロサービス化

  • Ruby ServerをDockerコンテナ化
  • APIゲートウェイの導入
  • サービス間通信の最適化

13.2 リアルタイム機能

  • WebSocketによるリアルタイム順位更新
  • プッシュ通知機能の実装

13.3 多言語対応

  • 国際大会対応のため多言語化
  • タイムゾーン対応の強化

この詳細機能設計書により、外部システム連携の実装、運用、保守に必要な全ての技術的詳細が文書化されています。


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

14.1 一括写真アップロード・自動チェックイン機能

機能概要

複数の写真を一括でアップロードし、EXIF情報から自動的にチェックイン処理を実行する機能です。

技術仕様

実装ファイル: rog/views_apis/api_bulk_upload.py

主要クラス・関数:

def bulk_upload_photos(request):
    """
    一括写真アップロード処理
    
    処理フロー:
    1. アップロードファイルの検証
    2. EXIF情報抽出GPS座標、撮影時刻
    3. 近接チェックポイント検索
    4. 自動チェックイン処理
    5. S3ストレージへの画像保存
    """

依存ライブラリ:

  • piexif: EXIF情報抽出
  • PIL: 画像処理
  • boto3: S3連携

処理フロー図:

写真アップロード → EXIF抽出 → GPS検証 → チェックポイント検索 → 自動チェックイン
      ↓              ↓           ↓            ↓                ↓
   ファイル検証    座標・時刻   位置精度確認  距離計算         DB記録
      ↓              ↓           ↓            ↓                ↓
   S3アップロード  メタデータ   タイムスタンプ  ポイント照合    確定処理

GPS精度検証ロジック

def validate_gps_proximity(gps_coords, checkin_time, event_code):
    """
    GPS座標の妥当性検証
    
    検証項目:
    - チェックポイントとの距離50m以内
    - 撮影時刻の妥当性(イベント期間内)
    - 重複チェックイン防止同一CP 30分以内
    """
    max_distance = 50  # メートル
    min_interval = 30  # 分

エラーハンドリング

class BulkUploadError(Exception):
    """一括アップロード専用例外クラス"""
    pass

# エラーパターン
- GPS情報なし: "GPS情報が見つかりません"
- 距離超過: "チェックポイントから50m以上離れています"
- 時刻異常: "撮影時刻がイベント期間外です"
- ファイル形式: "対応していないファイル形式です"

14.2 通過審査管理機能

機能概要

チェックインの確定・否認を管理し、審査状況を追跡する機能です。

データベース拡張

テーブル: rog_gpsCheckin

新規フィールド:

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;
ALTER TABLE rog_gpsCheckin ADD COLUMN validated_by VARCHAR(255);

ステータス定義:

VALIDATION_STATUS_CHOICES = [
    ('PENDING', '審査待ち'),
    ('APPROVED', '承認'),
    ('REJECTED', '却下'),
    ('AUTO_APPROVED', '自動承認'),
]

API実装

実装ファイル: rog/views_apis/api_bulk_upload.py

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def confirm_checkin_validation(request):
    """
    チェックイン確定・否認処理
    
    パラメータ:
    - checkin_id: 対象チェックインID
    - action: APPROVED/REJECTED
    - comment: 審査コメント
    """

審査ワークフロー

写真アップロード → AUTO_APPROVED → 管理者審査 → APPROVED/REJECTED
      ↓                ↓              ↓              ↓
   自動処理         システム判定    手動確認       最終決定
      ↓                ↓              ↓              ↓
   即座反映         暫定得点       審査待ち       確定得点

14.3 全参加者ランキング表示機能

機能概要

イベント全参加者の得点と審査状況をクラス別に一覧表示する機能です。

実装仕様

実装ファイル: rog/views_apis/api_admin_validation.py

主要関数:

def get_event_participants_ranking(request):
    """
    全参加者ランキング取得
    
    集計項目:
    - 確定得点APPROVED
    - 未確定得点PENDING
    - 確定率(確定数/総数)
    - クラス別順位
    """

SQLクエリ最適化

SELECT 
    e.zekken_number,
    e.team_name,
    e.class_name,
    SUM(CASE WHEN g.validation_status = 'APPROVED' THEN l.point_value ELSE 0 END) as confirmed_points,
    SUM(CASE WHEN g.validation_status = 'PENDING' THEN l.point_value ELSE 0 END) as pending_points,
    COUNT(g.id) as total_checkins,
    COUNT(CASE WHEN g.validation_status = 'APPROVED' THEN 1 END) as confirmed_checkins
FROM rog_entry e
LEFT JOIN rog_gpsCheckin g ON e.zekken_number = g.zekken_number
LEFT JOIN rog_location2025 l ON g.cp_number = l.cp_number
WHERE e.event_code = %s
GROUP BY e.zekken_number, e.team_name, e.class_name
ORDER BY confirmed_points DESC;

キャッシュ戦略

from django.core.cache import cache

def get_cached_ranking(event_code):
    """
    ランキングデータのキャッシュ
    
    キャッシュキー: f"ranking_{event_code}"
    有効期限: 300秒5分
    更新トリガー: チェックイン確定時
    """
    cache_key = f"ranking_{event_code}"
    cached_data = cache.get(cache_key)
    
    if cached_data is None:
        cached_data = calculate_ranking(event_code)
        cache.set(cache_key, cached_data, 300)
    
    return cached_data

14.4 管理画面UI拡張

フロントエンド実装

実装ファイル: supervisor/html/index.html

新機能:

  1. 表示モード切り替え: 個別表示 ⇄ ランキング表示
  2. 一括操作: 確定・否認ボタン
  3. ファイルアップロード: ドラッグ&ドロップ対応
  4. リアルタイム更新: AJAX通信

JavaScript実装概要

class AdminValidationManager {
    constructor() {
        this.apiBaseUrl = '/api';
        this.currentEventCode = '';
        this.displayMode = 'individual';
    }
    
    // 一括写真アップロード
    async handleBulkPhotoUpload(files) {
        const formData = new FormData();
        files.forEach(file => formData.append('photos', file));
        formData.append('event_code', this.currentEventCode);
        
        const response = await fetch(`${this.apiBaseUrl}/bulk-upload-photos/`, {
            method: 'POST',
            headers: this.getAuthHeaders(),
            body: formData
        });
        
        return await response.json();
    }
    
    // チェックイン確定・否認
    async confirmCheckin(checkinId, action) {
        const response = await fetch(`${this.apiBaseUrl}/confirm-checkin-validation/`, {
            method: 'POST',
            headers: this.getAuthHeaders(),
            body: JSON.stringify({
                checkin_id: checkinId,
                action: action,
                comment: action === 'REJECTED' ? '手動確認により却下' : '手動確認により承認'
            })
        });
        
        return await response.json();
    }
}

レスポンシブデザイン

/* 管理画面専用スタイル */
.admin-panel {
    display: grid;
    grid-template-columns: 1fr 3fr;
    gap: 20px;
}

.ranking-table {
    overflow-x: auto;
    max-height: 70vh;
}

.validation-buttons {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
}

@media (max-width: 768px) {
    .admin-panel {
        grid-template-columns: 1fr;
    }
    
    .validation-buttons {
        flex-direction: column;
    }
}

14.5 セキュリティ対策

認証・認可

from rest_framework.permissions import IsAuthenticated
from django.contrib.auth.decorators import user_passes_test

def is_admin_user(user):
    """管理者権限チェック"""
    return user.is_authenticated and user.is_superuser

@user_passes_test(is_admin_user)
def admin_only_view(request):
    """管理者専用ビュー"""
    pass

ファイルアップロード検証

def validate_upload_file(file):
    """
    アップロードファイル検証
    
    検証項目:
    - ファイルサイズ10MB以下
    - ファイル形式JPEG/PNG
    - EXIF情報の存在
    - マルウェアスキャン
    """
    MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
    ALLOWED_TYPES = ['image/jpeg', 'image/png']
    
    if file.size > MAX_FILE_SIZE:
        raise ValidationError('ファイルサイズが制限を超えています')
    
    if file.content_type not in ALLOWED_TYPES:
        raise ValidationError('対応していないファイル形式です')

レート制限

from django_ratelimit.decorators import ratelimit

@ratelimit(key='user', rate='10/h', method=['POST'])
def bulk_upload_photos(request):
    """
    レート制限: 1時間あたり10回まで
    """
    pass

14.6 監視・ログ機能

操作ログ

import logging

logger = logging.getLogger('admin_operations')

def log_admin_action(user, action, target, details=None):
    """
    管理者操作ログ記録
    
    ログ項目:
    - 操作者
    - 操作内容
    - 対象データ
    - タイムスタンプ
    - 詳細情報
    """
    logger.info(f"Admin Action: {user.username} performed {action} on {target}", extra={
        'user_id': user.id,
        'action': action,
        'target': target,
        'details': details,
        'timestamp': timezone.now().isoformat()
    })

パフォーマンス監視

from django.db import connection
from django.conf import settings

def monitor_query_performance():
    """
    クエリパフォーマンス監視
    """
    if settings.DEBUG:
        query_count = len(connection.queries)
        slow_queries = [q for q in connection.queries if float(q['time']) > 0.1]
        
        if slow_queries:
            logger.warning(f"Slow queries detected: {len(slow_queries)} queries > 0.1s")