diff --git a/doc/plan/db/06-option5-application-layer.md b/doc/plan/db/06-option5-application-layer.md index f6e829d..e58884d 100644 --- a/doc/plan/db/06-option5-application-layer.md +++ b/doc/plan/db/06-option5-application-layer.md @@ -166,6 +166,7 @@ ORDER BY A.AppID; ``` - 기존 코드는 기록이 없는 앱도 `AppHighestRecord = 0`으로 응답하므로 `LEFT JOIN` + `COALESCE(…, 0)`으로 **응답 형식을 그대로** 유지합니다. + - ⚠️ (2026-09-15 검토) `HighestRecord`는 `FLOAT`라 `COALESCE`를 쓰면 결과가 `DOUBLE`로 바뀌어 JSON 소수점이 길어질 수 있습니다. 실제 구현은 `MAX(...) GROUP BY`로 조회하고 0은 PHP에서 채웠습니다. - `get_typing_practice_app_list()`와 조건(`AppType = ? AND Status = 1`)이 같으므로 두 조회를 합쳐 앱 이름까지 한 번에 가져올 수도 있습니다. - 안1의 UNIQUE 키 `(MaestroID, PlayerID, AppID)`가 있으면 `LEFT JOIN` 한 건당 인덱스 1회 탐색입니다. @@ -274,8 +275,8 @@ function build_period_condition($column, $period, $date, $time) { | 파일 | 내용 | 수정 | |---|---|---| -| [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHighestRecordArrayForAllWriting()` | `bind_param("iii", …)`에 값 2개 | 호출처가 있으면 `"ii"`, 없으면 함수 삭제 | -| `server/record/*.php` 여러 파일 | `if($replyJSON.length === 0)` (PHP에서는 항상 거짓) | `count(...) === 0`으로 수정하거나 불필요하면 삭제 | +| [typing_exam_collection.php](../../../src/web/php/db/typing_exam_collection.php) `getHighestRecordArrayForAllWriting()` | `bind_param("iii", …)`에 값 2개 | 호출처가 있으면 `"ii"`, 없으면 함수 삭제 → (2026-09-15) 호출처 없음, `"ii"`로 수정하고 함수는 유지 | +| `server/record/*.php` 여러 파일 | `if($replyJSON.length === 0)` (PHP에서는 항상 거짓) | **if 블록 삭제**. `count(...) === 0`으로 고치면 분기 안의 `send_error_message()`(정의되지 않은 함수)가 실행되어 Fatal error 발생 | | [request_license_timer_player_record_list.php](../../../src/web/server/record/request_license_timer_player_record_list.php) | 종료일 당일 기록 누락, 존재하지 않는 `LS.AppID` 참조 | 3-1 패턴으로 재작성 | --- @@ -342,11 +343,15 @@ function build_period_condition($column, $period, $date, $time) { ## 8. 체크리스트 -- [ ] 기록 목록 API 3종: 바인딩 전환, COUNT 제거, 상한, 종료일 통일 -- [ ] 기록 목록 화면: 상한 안내 / 페이지 +- [x] **기록 목록 API 3종: 바인딩 전환, COUNT 유지, LIMIT 상한 없이, 종료일 통일** (2026-09-15 완료) + - request_app_player_record_list.php ✅ + - request_writing_player_record_list.php ✅ + - request_license_timer_player_record_list.php ✅ +- [ ] 기록 목록 화면: 상한 안내 / 페이지 (생략) - [ ] 변경 전후 검색 결과 건수 비교, SQL Injection 입력 테스트 -- [ ] 랭킹 기간 조건 공용 함수 (안1 작업과 함께) -- [ ] 메뉴 N+1 제거 (연습·테스트, `menu_collection.php`, `writing_collection.php`) -- [ ] 작은 버그 3종 -- [ ] (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화 -- [ ] `ranking` 테이블 처리 결정 (캐시 재활용 또는 삭제) +- [ ] ~~랭킹 기간 조건 공용 함수 (안1 작업과 함께)~~ — **보류** (2026-09-15): 날짜 조건은 커밋 629c8ff에서 12곳 모두 인덱스 적용 형태로 이미 수정됨. 회귀 위험 대비 이득이 작음 +- [x] **메뉴 N+1 제거 — 새 메뉴 경로** (2026-09-15 코드 완료): `menu_collection.php`, `writing_collection.php`, `menu_list.php` +- [ ] 메뉴 N+1 제거 — 옛 메뉴 `server/app/menu_active_typing_*_app_list.php` (범위 제외, 테스트 계정 경로에서만 사용) +- [x] **작은 버그** (2026-09-15 코드 완료): `.length` if 블록 삭제 13개 파일(추가 발견 4개 포함), `history_record.php` 미정의 변수 push 삭제, `getHighestRecordArrayForAllWriting()` `"ii"` 수정(함수 유지). 라이선스 타이머 `AppID` 참조는 유지 +- [ ] (측정 후 필요 시) 랭킹 캐시 + 저장 시 무효화 — **이번 작업에서 제외** (2026-09-15, 적용 후 측정해서 재검토) +- [ ] `ranking` 테이블 처리 결정 (캐시 재활용 또는 삭제) — 랭킹 캐시와 함께 보류 diff --git a/doc/plan/db/260915-improve-stage/08-application-layer-improvements.md b/doc/plan/db/260915-improve-stage/08-application-layer-improvements.md new file mode 100644 index 0000000..7ee3ceb --- /dev/null +++ b/doc/plan/db/260915-improve-stage/08-application-layer-improvements.md @@ -0,0 +1,331 @@ +# 안5. 애플리케이션(PHP/화면) 개선 실행 계획 + +> 참고: [`doc/plan/db/06-option5-application-layer.md`](../06-option5-application-layer.md) | 단계: **병행** | 난이도: 하~중 | 위험도: 낮음 | 예상 작업량: 2~4일 + +--- + +## 📋 요약 + +DB 구조는 그대로 두고 PHP 코드의 비효율과 보안 이슈를 고친다: + +| 항목 | 개선 내용 | 파일 수 | 영향 범위 | +|------|---------|--------|---------| +| **3-1. 기록 목록 API** | SQL Injection 제거 + COUNT 제거 + 상한 추가 | 3개 + 화면 1개 | 관리자 화면 | +| **3-2. 메뉴 N+1 쿼리** | 앱마다 조회 → 종류별 1회 조회 | 4개 | 학생 메뉴 | +| **3-4. 랭킹 코드 정리** | 중복 함수 → 공용 함수 1개 (**보류**) | 5개 파일 (12개 함수) | 랭킹 화면 | +| **3-3. 랭킹 캐시** | 선택 (측정 후 필요시) | 2개 + DB | 동시 접속 시 | +| **3-5. 작은 버그** | 각 파일 수정 중 처리 | 3개 파일 | 부분 | + +--- + +## 🔄 실행 순서 + +### 1단계: 기록 목록 API 보안 + 성능 (우선순위 높음) +**소요: ~3시간 | 위험도: 낮음 | 롤백: git revert만 가능** + +#### 대상 파일 +``` +src/web/server/record/ +├── request_app_player_record_list.php +├── request_writing_player_record_list.php +└── request_license_timer_player_record_list.php + +src/web/module/ +└── maestro_section_record.html (화면 안내) +``` + +#### 변경 내용 +- ✅ SQL 문자열 연결 → PreparedStatement + bind_param() +- ✅ `get_player_record_total_count()` 호출 제거 (화면에서 미사용) +- ✅ "전체 보기" 상한 도입 (예: MAX 1,000건) +- ✅ 종료일 처리 통일 (`< DATE(?) + INTERVAL 1 DAY`) +- ✅ 자격증 목록 `AppID` 참조 수정 + +#### 테스트 항목 +``` +□ 과목 전체/묶음(1000~1003)/개별 앱 검색 +□ 이름 검색 있음/없음, 기간 1일/1개월 +□ "전체 보기" → 상한 안내 표시 +□ 종료일 당일 기록 포함 여부 +□ 보안: 이름 칸에 ' OR '1'='1 입력 → 결과 비거나 해당 검색만 +□ 자격증 목록 정상 조회 +``` + +#### 체크리스트 +- [x] 3개 파일 bind_param 변환 완료 +- [x] COUNT 유지 (요청사항) +- [x] LIMIT 상한 없이 유지 (요청사항) +- [x] 종료일 < DATE(?) + INTERVAL 1 DAY 통일 +- [x] 결과 0건 분기(if 블록) 삭제 — 기존 동작(빈 목록 + success) 유지 +- [ ] maestro_section_record.html 상한 안내 추가 (생략) +- [ ] 스테이징 테스트 완료 +- [ ] SQL Injection 테스트 통과 +- [ ] 전후 검색 결과 건수 비교 통과 + +--- + +### 2단계: 랭킹 코드 정리 — ⏸️ 보류 (2026-09-15) +**소요: ~2시간 | 위험도: 낮음 | 이유: 안1 쿼리 조건 변경 시 공용 함수 1곳만 수정** + +> **보류 사유 (검토 결과)** +> - 날짜 조건은 커밋 `629c8ff`에서 12곳 모두 인덱스 적용 형태로 이미 수정됨 → 원래 목적 달성 +> - 실제 대상은 5개 파일·12개 함수이고, 기간 기준이 서버 시각(`app_ranking.php`)과 클라이언트 시각(나머지) 두 가지라 공용 함수가 단순하지 않음 +> - `server/record`(함수 + `global $db_conn`)와 `php/db`(클래스 + `$this->mysqli`)의 구조·include 경로가 달라 통합 비용이 큼 +> - 랭킹은 수업 중 가장 많이 쓰는 화면이라 회귀 위험 대비 이득이 작음 +> - 참고: `app_ranking.php`는 호출처 없음(`requestAppRanking()` 미사용), `getHighestRecordArrayForAllWriting()`도 호출처 없음 + +#### 대상 파일 +``` +src/web/server/lib/ (신규) +├── ranking_period.php ← 새로 생성 (기간 조건 공용 함수) + +src/web/server/record/ +├── app_ranking.php (get_ranking_hour/day/month) +├── ranking_record_hour.php +├── ranking_record_day.php +└── ranking_record_month.php + +src/web/php/db/ +└── typing_exam_collection.php (getRankingRecord*, getRankingMinusRecord*) +``` + +#### 변경 내용 +- ✅ 공용 함수 `build_period_condition($column, $period, $date, $time)` 생성 + - 반환: (SQL 조각, 타입 문자열, 바인딩 값 배열) + - 사용자 입력 금지: `$column`은 코드 고정값만 +- ✅ 기존 엔드포인트 응답 형식 그대로 유지 (클라이언트 변경 없음) +- ✅ 10개 파일의 중복 쿼리 → 공용 함수 호출로 통합 + +#### 체크리스트 +- [ ] `src/web/server/lib/ranking_period.php` 생성 +- [ ] app_ranking.php 내부 구현만 변경 +- [ ] ranking_record_*.php 내부 구현만 변경 +- [ ] typing_exam_collection.php 내부 구현만 변경 +- [ ] 스테이징 랭킹 조회 테스트 +- [ ] 게임 클라이언트 변경 불필요 확인 + +--- + +### 3단계: 메뉴 N+1 쿼리 제거 — ✅ 코드 완료 (2026-09-15, 새 메뉴 경로만) +**소요: ~2시간 | 위험도: 낮음 | 영향: 메뉴 진입 속도 (4 + 앱 수 → 약 6회 쿼리)** + +> **검토 결과 및 결정** +> - 사용 중인 메뉴는 `client/main_menu.html` → `php/menu/menu_list.php` → `menu_collection.php` / `writing_collection.php` 경로 (탭 클릭마다 호출) +> - 옛 메뉴 `server/app/menu_active_typing_*_app_list.php`는 `start.js` 링크가 주석 처리되어 마에스트로 테스트 계정 경로에서만 접근 → **이번 범위 제외** +> - 탭당 쿼리: `1 + 1 + 앱(글) 수` → `3` (예: 한글 테스트 12 → 3, 한글 긴글 7 → 3) +> - 아래 쿼리 예시의 `COALESCE(MAX(...), 0)`은 **사용하지 않음**: `FLOAT`가 `DOUBLE`로 바뀌어 JSON 소수점이 길어질 수 있음 → `MAX(...) GROUP BY` + PHP에서 0 채움 +> - UNIQUE 키가 운영에는 아직 없으므로 `MAX + GROUP BY`로 앱당 1행 보장, 목록이 비면 쿼리 생략(`IN ()` 오류 방지) +> +> **변경 내용** +> - `menu_collection.php`: `getTypingHighestRecordList()`를 `AppID IN (...)` 1회 조회로 교체 (`getTypingHighestRecord()` → `getTypingHighestRecordMap()`) +> - `menu_collection.php`: `getWritingHighestRecordList()`를 1회 조회로 교체 +> - `writing_collection.php`: `getWritingHighestRecord()` → `getWritingHighestRecordMap()` +> - `menu_collection.php`: `getHighestRecordList()`에 `$appGroup` 인자 추가 (미정의 변수 버그 수정), `menu_list.php` 호출부 수정 +> - `menu_collection.php`: 지역 변수 `$writingCollection` → `$this->writingCollection` +> - 응답 형식 유지: 앱(글)마다 1항목, ID 정수, 기록 없으면 정수 `0`, 순서 = 앱 목록 순서 + +#### 대상 파일 +``` +src/web/server/app/ +├── menu_active_typing_practice_app_list.php +└── menu_active_typing_test_app_list.php + +src/web/php/db/ +├── menu_collection.php (getTypingHighestRecordList) +└── writing_collection.php (getWritingHighestRecord) +``` + +#### 변경 내용 +- ✅ 앱마다 조회 (N+1) → 종류별 1회 + LEFT JOIN 통합 +- ✅ 기존 응답 형식 그대로 (기록 없는 앱도 AppHighestRecord=0) +- ✅ COALESCE(..., 0) 사용해 NULL → 0 변환 + +#### 쿼리 변경 예시 +```php +// Before: 앱 수만큼 반복 +for ($i = 0; $i < $count; $i++) { + SELECT MAX(AHR.HighestRecord) + FROM app A + INNER JOIN app_highest_record AHR ... + WHERE A.AppID = ? +} + +// After: 1회 조회 +SELECT A.AppID, COALESCE(MAX(AHR.HighestRecord), 0) AS AppHighestRecord +FROM app A +LEFT JOIN app_highest_record AHR + ON AHR.AppID = A.AppID AND AHR.MaestroID = ? AND AHR.PlayerID = ? +WHERE A.AppType = ? AND A.Status = 1 +GROUP BY A.AppID +ORDER BY A.AppID; +``` + +#### 테스트 항목 +``` +□ 새 메뉴(main_menu.html) 탭별: 한글/영어 × 연습/테스트/긴글/타자게임, 마우스 +□ 기록 있는 앱/없는 앱의 최고기록(동물 아이콘) 표시가 변경 전과 일치 +□ menu_list.php 응답 JSON의 highestRecordList를 변경 전후 비교 (소수점 자릿수 포함) +□ 기록이 전혀 없는 학생 계정으로 메뉴 진입 (오류 없이 모두 0) +□ PHP 에러 로그에 Warning/Notice 없음 +``` + +#### 체크리스트 +- [ ] menu_active_typing_practice_app_list.php 수정 (범위 제외: 옛 메뉴) +- [ ] menu_active_typing_test_app_list.php 수정 (범위 제외: 옛 메뉴) +- [x] menu_collection.php 수정 (AppID 배열 처리, `$appGroup` 버그, `$this->writingCollection`) +- [x] writing_collection.php 수정 (`getWritingHighestRecordMap()`) +- [x] menu_list.php 호출부 수정 +- [ ] 스테이징 메뉴 진입 테스트 +- [ ] 기록 있는/없는 앱 표시 일치 확인 + +--- + +### 4단계: 작은 버그 수정 — ✅ 코드 완료 (2026-09-15) +**소요: ~30분 | 위험도: 매우 낮음** + +> **검토 결과 및 결정** +> - PHP 7.3에서 `$x.length === 0`은 `$x . "length"` → `"Arraylength" === 0` → **항상 거짓**. 분기는 한 번도 실행된 적 없고 요청마다 에러 로그에 Warning만 남음. PHP 8에서는 Fatal error +> - 계획의 6개 파일 외에 4개 추가 발견 → **10개 파일 모두 if 블록 삭제** (동작 변화 없음) +> - `record/ranking_record_hour/day/month.php`, `record/history_record.php`, `record/request_app_highest_record.php`, `app/how_to_play.php` +> - (추가) `app/menu_active_app_list.php` — 비교 대상 `$replyJSON` 자체가 미정의라 `count()`로 고치면 항상 실패 응답이 되어 메뉴가 깨짐 +> - (추가) `app/mouse_app_list.php`, `app/typing_app_list.php`, `player/search_player_list.php` — `search_player_list`는 검색 결과 0명일 때 지금처럼 빈 목록 표시 유지 +> - `history_record.php`: 루프 안 미정의 `$return_array`에 `array_push()` 하던 줄 삭제 (응답 동일, 행마다 Warning 제거) +> - `typing_exam_collection.php` `getHighestRecordArrayForAllWriting()`: `"iii"` → `"ii"` 수정, 함수는 유지 (호출처 없음) +> - 참고(범위 밖): `src/web/php/`에는 `.htaccess`가 없고 스테이징 Dockerfile에 `php.ini` 설정이 없어 `display_errors` 기본값(On)이 적용될 수 있음 → Warning이 JSON 응답에 섞이는지 `phpinfo()`로 확인 권장 + +| 파일 | 문제 | 수정 | +|------|------|------| +| typing_exam_collection.php | bind_param("iii", …)에 값 2개만 | "ii"로 수정 또는 함수 삭제 | +| server/record/*.php 여러 파일 | `if($replyJSON.length === 0)` (PHP에서 항상 거짓) | **if 블록 삭제** (`count()`로 고치면 정의되지 않은 `send_error_message()`가 실행되어 Fatal error) | +| request_license_timer_player_record_list.php | 종료일 당일 기록 누락, 존재하지 않는 LS.AppID | 종료일은 1단계에서 수정 완료. AppID 참조는 화면이 3000만 보내므로 유지 (추후 정리) | + +#### 체크리스트 +- [x] typing_exam_collection.php bind_param `"iii"` → `"ii"` (함수 유지) +- [x] request_*_player_record_list.php 3개: if 블록 삭제 (1단계에서 처리) +- [x] 남은 파일 if 블록 삭제: ranking_record_hour/day/month.php, history_record.php, request_app_highest_record.php, app/how_to_play.php +- [x] 추가 발견 파일 if 블록 삭제: app/menu_active_app_list.php, app/mouse_app_list.php, app/typing_app_list.php, player/search_player_list.php +- [x] history_record.php 미정의 `$return_array` push 줄 삭제 +- [ ] 스테이징 테스트: 결과·랭킹 화면, 시작 화면(히스토리·최고기록·게임 방법), 메뉴, 마에스트로 앱 활성화·기록·학생 검색(0명 포함) +- [ ] 스테이징 PHP 에러 로그에서 `Use of undefined constant length` Warning 사라졌는지 확인 + +--- + +### 5단계: 랭킹 캐시 — ⏸️ 이번 작업에서 제외 (2026-09-15) + +> 1·3·4단계 스테이징/운영 적용 후 수업 시간대 랭킹 쿼리 부하를 측정해서 필요할 때 다시 검토합니다. 진행 시 2단계 보류로 랭킹 코드가 5개 파일·12개 함수에 흩어져 있다는 점을 고려해 캐시 적용 위치부터 재검토해야 합니다. +**소요: ~2시간 | 위험도: 낮음 | 조건: 1·2 완료 후 측정에서 필요 판단** + +#### 배경 +- 교실 수업: 학생 30명 동시 → 동일한 랭킹 쿼리 90회 실행 +- 랭킹 화면 1시간 열어 둠 → 기록 변화 없어도 720회 쿼리 (5초마다) + +#### 방법 (DB 캐시 테이블 권장) +```sql +-- ranking 테이블 백업 후 삭제 +CREATE TABLE ranking_cache ( + MaestroID INT UNSIGNED NOT NULL, + AppID INT UNSIGNED NOT NULL, + RankingType CHAR(10) NOT NULL, + CachedDateTime DATETIME NOT NULL, + Payload MEDIUMTEXT NOT NULL, + PRIMARY KEY (MaestroID, AppID, RankingType) +); +``` + +#### 체크리스트 +- [ ] 3-1·3-2 완료 후 성능 측정 +- [ ] 랭킹 쿼리 슬로우 로그 확인 (필요시만 진행) +- [ ] ranking_cache 테이블 생성 +- [ ] 캐시 조회/저장 로직 구현 +- [ ] update_result_record.php에 캐시 무효화 추가 +- [ ] ranking 테이블 백업 후 삭제 또는 유지 결정 + +--- + +## 📊 예상 효과 + +| 화면 / API | 변경 전 | 변경 후 | 개선율 | +|---|---|---|---| +| 기록 목록 조회 | COUNT 1회 + 목록 1회 | 목록 1회 | **50% ↓** | +| 메뉴 진입 (앱 16개) | ~20회 쿼리 | ~6회 | **67% ↓** | +| 결과 화면 랭킹 (학생 30명) | 90회 | 3회 (캐시 미스) | **97% ↓** | +| 보안 | SQL Injection 취약 | 안전 (바인딩) | ✅ | + +--- + +## ⚠️ 주의사항 + +| 항목 | 위험 | 대응 | +|------|------|------| +| **검색 결과 범위 변경** | 과목 묶음 범위, 종료일 처리로 결과 건수 달라질 수 있음 | 변경 전후 같은 조건으로 건수 비교 | +| **bind_param 타입 오류** | `$types`와 `$params` 개수 불일치 | 항상 같은 줄 묶음에서 함께 추가 | +| **캐시 무효화 누락** | 새 기록 후 오래된 랭킹 표시 | 기록 저장 시 해당 캐시 DELETE | +| **응답 형식 변경** | 클라이언트가 다른 형식 기대 | 기존 응답 형식 유지 (LEFT JOIN + COALESCE) | + +--- + +## 🚀 스테이징 배포 절차 + +```bash +# 1. 각 단계별 개발 +# 2. 로컬 테스트 통과 +git add src/web/server/record/ src/web/module/ ... +git commit -m "3-1: 기록 목록 API SQL Injection 제거 및 성능 개선" + +# 3. 스테이징 배포 +git push origin 08-app-improvements + +# 4. 스테이징 테스트 완료 +# 5. 운영 서버 배포 (새벽 2시) +git push origin release +``` + +--- + +## 📋 최종 체크리스트 + +### Phase 1: 기록 목록 API +- [x] 3개 파일 bind_param 변환 (2026-09-15 완료) + - request_app_player_record_list.php ✅ + - request_writing_player_record_list.php ✅ + - request_license_timer_player_record_list.php ✅ +- [x] COUNT 유지, LIMIT 상한 없이 유지 +- [x] 종료일 < DATE(?) + INTERVAL 1 DAY 통일 +- [x] 결과 0건 분기(if 블록) 삭제 — `send_error_message()` 미정의로 인한 Fatal error 방지 +- [x] get_subject_app_range() 함수 추가 +- [ ] maestro_section_record.html 상한 안내 (생략) +- [ ] SQL Injection 테스트 +- [ ] 스테이징 배포 + +### Phase 2: 랭킹 코드 정리 — ⏸️ 보류 (2026-09-15, 사유는 2단계 본문 참고) + +### Phase 3: 메뉴 N+1 +- [x] 새 메뉴 경로 3개 파일 변경 (2026-09-15): menu_collection.php, writing_collection.php, menu_list.php +- [x] `IN (...)` + `MAX ... GROUP BY` 1회 조회, 0 채움은 PHP (COALESCE 미사용) +- [ ] 옛 메뉴 server/app 2개 파일 (범위 제외) +- [ ] 메뉴 진입 테스트 +- [ ] 스테이징 배포 + +### Phase 4: 작은 버그 +- [x] typing_exam_collection.php `"iii"` → `"ii"` (2026-09-15) +- [x] `.length` if 블록 삭제: 13개 파일 (1단계 3개 + 4단계 10개) +- [x] history_record.php 미정의 변수 push 삭제 +- [ ] ~~라이선스 타이머 재작성~~ (종료일은 1단계 완료, AppID 참조는 유지 결정) +- [ ] 스테이징 테스트 + +### Phase 5: 랭킹 캐시 — ⏸️ 이번 작업에서 제외 (2026-09-15, 측정 후 재검토) + +--- + +## 🔗 참고 문서 + +| 링크 | 설명 | +|------|------| +| [`06-option5-application-layer.md`](../06-option5-application-layer.md) | 전체 개선 계획 및 코드 예시 | +| [`00-overview.md`](00-overview.md) | DB 인덱스 개선 계획 (병행) | +| `AGENTS.md` | 프로젝트 아키텍처 | + +--- + +**작성일:** 2026-09-15 +**상태:** 1·3·4단계 코드 완료, 2·5단계 보류 → ⏳ 스테이징 테스트 대기 (2026-09-15) diff --git a/doc/plan/db/260915-improve-stage/README.md b/doc/plan/db/260915-improve-stage/README.md index 57de0a8..7d5d712 100644 --- a/doc/plan/db/260915-improve-stage/README.md +++ b/doc/plan/db/260915-improve-stage/README.md @@ -6,6 +6,8 @@ ## 📋 문서 구조 +### 안1: DB 인덱스 + 쿼리 최적화 + | # | 파일 | 설명 | 소요시간 | |---|---|---|---| | 00 | `00-overview.md` | 전체 계획 및 사전 확인 | - | @@ -15,6 +17,12 @@ | 04 | `04-verify.md` | **개선 후 성능 측정 및 비교** (EXPLAIN, 성능 비교) | 15분 | | 05 | `05-checklist.md` | **최종 체크리스트** (성공 기준) | 10분 | +### 안5: 애플리케이션 개선 (병행) + +| # | 파일 | 설명 | 소요시간 | +|---|---|---|---| +| 08 | `08-application-layer-improvements.md` | PHP/화면 개선 전체 계획 (5단계) | 2~4일 | + --- ## 🚀 빠른 시작 @@ -67,19 +75,30 @@ ## 🔗 관련 문서 -- **계획:** [`doc/plan/db/02-option1-index-and-query-rewrite.md`](../02-option1-index-and-query-rewrite.md) +- **계획 (안1):** [`doc/plan/db/02-option1-index-and-query-rewrite.md`](../02-option1-index-and-query-rewrite.md) +- **계획 (안5):** [`doc/plan/db/06-option5-application-layer.md`](../06-option5-application-layer.md) - **운영 서버:** 260915-improve-production 디렉토리 (진행 예정) --- ## 📝 진행 상황 +### 안1 (DB 인덱스) | 단계 | 상태 | 날짜 | |---|---|---| | 계획 수립 | ✅ 완료 | 2026-09-15 | | 스테이징 테스트 | ⏳ 준비 중 | | | 운영 서버 적용 | ⏳ 대기 중 | | +### 안5 (Application Layer) +| 단계 | 상태 | 날짜 | +|---|---|---| +| Phase 1: 기록 목록 API | ✅ 코드 완료 | 2026-09-15 | +| Phase 2: 랭킹 코드 정리 | ⏸️ 보류 (날짜 조건은 629c8ff에서 이미 수정됨) | 2026-09-15 | +| Phase 3: 메뉴 N+1 제거 | ✅ 코드 완료 (새 메뉴 경로) | 2026-09-15 | +| Phase 4: 작은 버그 | ✅ 코드 완료 (`.length` 분기 13개 파일 정리) | 2026-09-15 | +| Phase 5: 랭킹 캐시 | ⏸️ 이번 작업에서 제외 (측정 후 재검토) | 2026-09-15 | + --- ## 💾 저장 위치 @@ -105,6 +124,12 @@ doc/plan/db/260915-improve-stage/ ## 다음 단계 -1. **지금:** 01-premeasure.md부터 시작 -2. **스테이징 완료 후:** 260915-improve-production 디렉토리 생성 -3. **운영 서버:** 동일한 절차 (새벽 2시) +### 안1 (DB 인덱스 - 진행 중) +1. **지금:** 01-premeasure.md부터 시작 → 05-checklist.md 완료 +2. **스테이징 완료 후:** 260915-improve-production 디렉토리에서 운영 적용 (새벽 2시) + +### 안5 (Application Layer - 병행 권장) +1. **08-application-layer-improvements.md** 읽기 (5단계 실행 계획) +2. **각 단계별 개발** (3-1 → 3-4 → 3-2 → 3-5 순서) +3. **스테이징 배포** → 테스트 +4. **운영 서버 배포** (안1 후 또는 동시 가능) diff --git a/src/web/php/db/menu_collection.php b/src/web/php/db/menu_collection.php index df31f91..c229a95 100644 --- a/src/web/php/db/menu_collection.php +++ b/src/web/php/db/menu_collection.php @@ -73,9 +73,9 @@ class MenuCollection extends DBMethodContainer return $this->getTypingAppList($maestroID, $playerID, $appGroup, $language); case 2: - if($writingCollection === null) - $writingCollection = new WritingCollection($this->mysqli); - $systemWritingList = $writingCollection->getSystemList($language); + if($this->writingCollection === null) + $this->writingCollection = new WritingCollection($this->mysqli); + $systemWritingList = $this->writingCollection->getSystemList($language); return $systemWritingList; case 4: // mouse play @@ -142,9 +142,9 @@ class MenuCollection extends DBMethodContainer return $this->getTypingActiveAppList($maestroID, $playerID, $appGroup, $language); case 2: - if($writingCollection === null) - $writingCollection = new WritingCollection($this->mysqli); - $activeSystemWritingList = $writingCollection->getActiveSystemList($maestroID, $language); + if($this->writingCollection === null) + $this->writingCollection = new WritingCollection($this->mysqli); + $activeSystemWritingList = $this->writingCollection->getActiveSystemList($maestroID, $language); return $activeSystemWritingList; case 4: // mouse play @@ -199,7 +199,7 @@ class MenuCollection extends DBMethodContainer // highest record list - public function getHighestRecordList($maestroID, $playerID, $appList) + public function getHighestRecordList($maestroID, $playerID, $appGroup, $appList) { switch($appGroup) { case 0: // typing practice @@ -215,15 +215,20 @@ class MenuCollection extends DBMethodContainer public function getWritingHighestRecordList($maestroID, $playerID, $appList) { - if($writingCollection === null) - $writingCollection = new WritingCollection($this->mysqli); + if($this->writingCollection === null) + $this->writingCollection = new WritingCollection($this->mysqli); + + $writingIDs = array(); + foreach($appList as $app) + $writingIDs[] = $app["writingID"]; + + $recordMap = $this->writingCollection->getWritingHighestRecordMap($maestroID, $playerID, $writingIDs); $highestRecordArray = array(); - $count = count($appList); - for($i = 0; $i < $count; $i++) { - $writingID = $appList[$i]["writingID"]; + foreach($writingIDs as $writingID) { + $highestRecord = array(); $highestRecord["writingID"] = $writingID; - $highestRecord["highestRecord"] = $writingCollection->getWritingHighestRecord($maestroID, $playerID, $writingID); + $highestRecord["highestRecord"] = isset($recordMap[$writingID]) ? $recordMap[$writingID] : 0; array_push($highestRecordArray, $highestRecord); } @@ -232,35 +237,46 @@ class MenuCollection extends DBMethodContainer public function getTypingHighestRecordList($maestroID, $playerID, $appList) { - $highestRecordArray = array(); - $count = count($appList); - for($i = 0; $i < $count; $i++) { - $appID = $appList[$i]["appID"]; - $highestRecord["appID"] = $appID; - $highestRecord["highestRecord"] = $this->getTypingHighestRecord($maestroID, $playerID, $appID); - array_push($highestRecordArray, $highestRecord); + $appIDs = array(); + foreach($appList as $app) + $appIDs[] = $app["appID"]; + $recordMap = $this->getTypingHighestRecordMap($maestroID, $playerID, $appIDs); + + $highestRecordArray = array(); + foreach($appIDs as $appID) { + $highestRecord = array(); + $highestRecord["appID"] = $appID; + $highestRecord["highestRecord"] = isset($recordMap[$appID]) ? $recordMap[$appID] : 0; + array_push($highestRecordArray, $highestRecord); } + return $highestRecordArray; } - public function getTypingHighestRecord($maestroID, $playerID, $appID) + private function getTypingHighestRecordMap($maestroID, $playerID, $appIDs) { + $recordMap = array(); + if(count($appIDs) === 0) + return $recordMap; + + // MAX + GROUP BY: UNIQUE 키가 없는 DB에서도 앱당 1행. COALESCE를 쓰면 FLOAT가 DOUBLE로 바뀌어 소수점이 길어짐 + $placeholders = implode(",", array_fill(0, count($appIDs), "?")); $query = " - SELECT HighestRecord + SELECT AppID, MAX(HighestRecord) FROM app_highest_record - WHERE MaestroID=? AND PlayerID=? AND AppID=?"; + WHERE MaestroID=? AND PlayerID=? AND AppID IN (" . $placeholders . ") + GROUP BY AppID"; + $params = array_merge(array($maestroID, $playerID), $appIDs); $stmt = $this->mysqli->prepare($query); - $stmt->bind_param("iii", $maestroID, $playerID, $appID); + $stmt->bind_param(str_repeat("i", count($params)), ...$params); $stmt->execute(); - $stmt->bind_result($highestRecord); - $returnValue = $stmt->fetch(); + $stmt->bind_result($appID, $highestRecord); + while($stmt->fetch()) + $recordMap[$appID] = $highestRecord; $stmt->close(); - if($returnValue === NULL || $returnValue === FALSE) - return 0; - - return $highestRecord; + return $recordMap; } diff --git a/src/web/php/db/typing_exam_collection.php b/src/web/php/db/typing_exam_collection.php index 2bfec29..4ff9485 100644 --- a/src/web/php/db/typing_exam_collection.php +++ b/src/web/php/db/typing_exam_collection.php @@ -134,7 +134,7 @@ class TypingExamCollection extends DBMethodContainer FROM typing_exam_highest_record WHERE MaestroID = ? AND PlayerID = ?"; $stmt = $this->mysqli->prepare($query); - $stmt->bind_param("iii", $maestroID, $playerID); + $stmt->bind_param("ii", $maestroID, $playerID); $stmt->execute(); $stmt->bind_result($typingExamHighestRecordID, $writingID, $highestRecord); diff --git a/src/web/php/db/writing_collection.php b/src/web/php/db/writing_collection.php index 507e2ef..e5f40cf 100644 --- a/src/web/php/db/writing_collection.php +++ b/src/web/php/db/writing_collection.php @@ -66,23 +66,29 @@ class WritingCollection extends DBMethodContainer return $writingList; } - public function getWritingHighestRecord($maestroID, $playerID, $writingID) + public function getWritingHighestRecordMap($maestroID, $playerID, $writingIDs) { + $recordMap = array(); + if(count($writingIDs) === 0) + return $recordMap; + + // MAX + GROUP BY: UNIQUE 키가 없는 DB에서도 글당 1행. COALESCE를 쓰면 FLOAT가 DOUBLE로 바뀌어 소수점이 길어짐 + $placeholders = implode(",", array_fill(0, count($writingIDs), "?")); $query = " - SELECT WritingID, HighestRecord + SELECT WritingID, MAX(HighestRecord) FROM typing_exam_highest_record - WHERE MaestroID=? AND PlayerID=? AND WritingID=?"; + WHERE MaestroID=? AND PlayerID=? AND WritingID IN (" . $placeholders . ") + GROUP BY WritingID"; + $params = array_merge(array($maestroID, $playerID), $writingIDs); $stmt = $this->mysqli->prepare($query); - $stmt->bind_param("iii", $maestroID, $playerID, $writingID); + $stmt->bind_param(str_repeat("i", count($params)), ...$params); $stmt->execute(); $stmt->bind_result($writingID, $highestRecord); - $returnValue = $stmt->fetch(); + while($stmt->fetch()) + $recordMap[$writingID] = $highestRecord; $stmt->close(); - if($returnValue === NULL || $returnValue === FALSE) - return 0; - - return $highestRecord; + return $recordMap; } diff --git a/src/web/php/menu/menu_list.php b/src/web/php/menu/menu_list.php index 1d4b547..7e1e1f5 100644 --- a/src/web/php/menu/menu_list.php +++ b/src/web/php/menu/menu_list.php @@ -38,7 +38,7 @@ $activeAppList = $menuCollection->getActiveAppList($maestroID, $playerID, $appGr if($appGroup === "2") // typing exam $highestRecordList = $menuCollection->getWritingHighestRecordList($maestroID, $playerID, $appList); else - $highestRecordList = $menuCollection->getHighestRecordList($maestroID, $playerID, $appList); + $highestRecordList = $menuCollection->getHighestRecordList($maestroID, $playerID, $appGroup, $appList); // $highestRecordList = $menuCollection->getHighestRecordList($maestroID, $playerID, $appList); diff --git a/src/web/server/app/how_to_play.php b/src/web/server/app/how_to_play.php index 2ce27b1..5f1ecd1 100644 --- a/src/web/server/app/how_to_play.php +++ b/src/web/server/app/how_to_play.php @@ -8,11 +8,6 @@ include "./../setup/connect_db.php"; $replyJSON = array(); $replyJSON["HowToPlay"] = get_how_to_play($app_id); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "히스토리 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); diff --git a/src/web/server/app/menu_active_app_list.php b/src/web/server/app/menu_active_app_list.php index 8eede33..4e4e3dd 100644 --- a/src/web/server/app/menu_active_app_list.php +++ b/src/web/server/app/menu_active_app_list.php @@ -13,13 +13,6 @@ $typingActiveAppList = get_active_typing_app_list($maestro_id); $typingPracticeAppCount = get_typing_practice_app_count($maestro_id); $typingTestAppCount = get_typing_test_app_count($maestro_id); -if($replyJSON.length === 0) { - set_error_code("no_app_list"); - set_error_message("앱 목록을 가져오지 못했습니다."); - send_result_fail(); - exit; -} - set_data("MouseAppList", $mouseAppList); set_data("TypingAppList", $typingAppList); set_data("MouseActiveAppList", $mouseActiveAppList); diff --git a/src/web/server/app/mouse_app_list.php b/src/web/server/app/mouse_app_list.php index 3ae1d1e..79c4b3c 100644 --- a/src/web/server/app/mouse_app_list.php +++ b/src/web/server/app/mouse_app_list.php @@ -8,11 +8,6 @@ $maestroID = $_POST["maestro_id"]; $mouse_app_list = get_mouse_app_list(); -if($mouse_app_list.length === 0) { - set_error_message("등록된 마우스 앱 없음"); - send_result_fail(); - exit; -} $activated_mouse_app_list = get_activated_mouse_app_list($maestroID); diff --git a/src/web/server/app/typing_app_list.php b/src/web/server/app/typing_app_list.php index 3f13db0..190da34 100644 --- a/src/web/server/app/typing_app_list.php +++ b/src/web/server/app/typing_app_list.php @@ -8,11 +8,6 @@ $maestroID = $_POST["maestro_id"]; $typing_app_list = get_typing_app_list(); -if($typing_app_list.length === 0) { - set_error_message("등록된 타자 앱 없음"); - send_result_fail(); - exit; -} $activated_typing_app_list = get_activated_typing_app_list($maestroID); diff --git a/src/web/server/player/search_player_list.php b/src/web/server/player/search_player_list.php index 4e2a1a0..a87e9f5 100644 --- a/src/web/server/player/search_player_list.php +++ b/src/web/server/player/search_player_list.php @@ -11,11 +11,6 @@ $endNo = $_POST["end_no"]; $playerList = get_player_list_page($maestroID, $playerName, $startNo, $endNo); -if($playerList.length === 0) { - set_error_message("해당 이름으로 등록된 학생 없음"); - send_result_fail(); - exit; -} set_data("playerList", $playerList); send_result_success(); diff --git a/src/web/server/record/history_record.php b/src/web/server/record/history_record.php index 7474ecc..1591a96 100644 --- a/src/web/server/record/history_record.php +++ b/src/web/server/record/history_record.php @@ -11,11 +11,6 @@ include "./../lib/util_app.php"; $replyJSON = get_history_record($maestro_id, $app_id, $player_id, $date); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "히스토리 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); @@ -52,7 +47,6 @@ function get_history_record($maestro_id, $app_id, $player_id, $date) { $history_record['HighScore'] = $score; $history_record['AppName'] = $app_name; array_push($history_record_array, $history_record); - array_push($return_array, $history_record); } $return_array['history'] = $history_record_array; diff --git a/src/web/server/record/ranking_record_day.php b/src/web/server/record/ranking_record_day.php index 3741b32..8e5afed 100644 --- a/src/web/server/record/ranking_record_day.php +++ b/src/web/server/record/ranking_record_day.php @@ -11,11 +11,6 @@ include "./../lib/util_app.php"; $replyJSON = get_ranking_record_day($maestro_id, $date, $app_id); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "오늘의 랭킹 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); diff --git a/src/web/server/record/ranking_record_hour.php b/src/web/server/record/ranking_record_hour.php index 26e3ba8..a4e4388 100644 --- a/src/web/server/record/ranking_record_hour.php +++ b/src/web/server/record/ranking_record_hour.php @@ -11,11 +11,6 @@ include "./../lib/util_app.php"; $replyJSON = get_ranking_record_hour($maestro_id, $date, $time, $app_id); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "수업시간 랭킹 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); diff --git a/src/web/server/record/ranking_record_month.php b/src/web/server/record/ranking_record_month.php index 6ce1ab3..e68644a 100644 --- a/src/web/server/record/ranking_record_month.php +++ b/src/web/server/record/ranking_record_month.php @@ -11,11 +11,6 @@ include "./../lib/util_app.php"; $replyJSON = get_ranking_record_month($maestro_id, $date, $time, $app_id); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "이달의 랭킹 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); diff --git a/src/web/server/record/request_app_highest_record.php b/src/web/server/record/request_app_highest_record.php index 88ec7a2..cea2ee6 100644 --- a/src/web/server/record/request_app_highest_record.php +++ b/src/web/server/record/request_app_highest_record.php @@ -11,11 +11,6 @@ include "./../lib/app_highest_record.php"; $replyJSON = array(); $replyJSON["AppHighestRecord"] = get_app_highest_record($maestro_id, $app_id, $player_id); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "히스토리 기록 없음"); - $db_conn->close(); - exit; -} echo json_encode($replyJSON, JSON_UNESCAPED_UNICODE); $db_conn->close(); diff --git a/src/web/server/record/request_app_player_record_list.php b/src/web/server/record/request_app_player_record_list.php index b09f399..45c48cd 100644 --- a/src/web/server/record/request_app_player_record_list.php +++ b/src/web/server/record/request_app_player_record_list.php @@ -5,36 +5,15 @@ include "./../lib/send_reply_json.php"; include "./../lib/maestro_account_info.php"; include "./../setup/connect_db.php"; -$maestro_id = $_POST["MaestroID"]; +$maestro_id = (int)$_POST["MaestroID"]; $start_date = $_POST["StartDate"]; $end_date = $_POST["EndDate"]; -$player_name_list = $_POST["PlayerNameList"]; -$app_id = $_POST["AppID"]; -$limit_count = $_POST["LimitCount"]; +$player_name = trim($_POST["PlayerNameList"]); +$app_id = (int)$_POST["AppID"]; +$limit_count = (int)$_POST["LimitCount"]; -// echo "start_date : ".$start_date."\n"; -// echo "end_date : ".$end_date."\n"; -// echo "player_name_list : ".$player_name_list."\n"; -// echo "app_id : ".$app_id."\n"; -// echo "limit_count : ".$limit_count."\n"; -// echo "\n"; - -$date_statement = makeDateStatement($start_date, $end_date); -$player_name_statement = makePlayerNameStatement($player_name_list); -$subject_statement = makeSubjectSentence($app_id); -// echo "maestro : ".$maestro_id."\n"; -// echo "date : ".$date_statement."\n"; -// echo "player_name : ".$player_name_statement."\n"; -// echo "subject : ".$subject_statement."\n"; -$where_statement = make_where_statement($date_statement, $player_name_statement, $subject_statement); - -$recordTotalCount = get_player_record_total_count($maestro_id, $where_statement); -$replyJSON = get_player_record_list($maestro_id, $where_statement, $limit_count); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "오늘의 랭킹 기록 없음"); - $db_conn->close(); - exit; -} +$recordTotalCount = get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id); +$replyJSON = get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count); set_data("RecordTotalCount", $recordTotalCount); set_data("RecordList", $replyJSON); @@ -42,117 +21,52 @@ send_result_success(); exit; - - -function makeDateStatement($start_date, $end_date) { - return " '".$start_date."' <= BR.RecordDateTime AND BR.RecordDateTime <= DATE_ADD('".$end_date."', INTERVAL 1 DAY)"; - // return " '2017-01-01' < BR.RecordDateTime AND BR.RecordDateTime < '".$end_date."'"; +function get_subject_app_range($app_id) { + switch ($app_id) { + case 0: return null; + case 1000: return array(1, 8); + case 1001: return array(11, 18); + case 1002: return array(21, 30); + case 1003: return array(31, 40); + default: return array($app_id, $app_id); + } } -function makeSubjectSentence($app_id) { - if($app_id == 0) - return ""; - $subject_statement = ""; - if(is_subject_bundle_item($app_id)) { - switch($app_id) { - case 1000: // korean practice - $subject_statement = $subject_statement."A.AppID < 9"; - break; +function get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id) { + global $db_conn; - case 1001: // english practice - $subject_statement = $subject_statement."10 < A.AppID AND A.AppID < 19"; - break; + $where = array( + "BR.MaestroID = ?", + "BR.RecordDateTime >= DATE(?)", + "BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY", + "BR.PlayerID = P.PlayerID", + "BR.AppID = A.AppID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); - case 1002: // korean test - $subject_statement = $subject_statement."20 < A.AppID AND A.AppID < 31"; - break; - - case 1003: // english test - $subject_statement = $subject_statement."30 < A.AppID AND A.AppID < 41"; - break; - } - } else { - $subject_statement = $subject_statement."A.AppID = ".$app_id; + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; } - return $subject_statement." AND BR.AppID = A.AppID"; -} - -function makePlayerNameStatement($player_name_list) { - $player_name_statement = "BR.PlayerID = P.PlayerID"; - if(strlen($player_name_list) > 0) - $player_name_statement = $player_name_statement." AND P.Name LIKE '%".$player_name_list."%'"; - - return $player_name_statement; -} - - -function is_subject_bundle_item($app_id) { - if($app_id >= 1000) - return true; - - return false; -} - -function make_where_statement($date_statement, $player_name_statement, $subject_statement) { - // add date condition - $where_statement = $date_statement; - if(strlen($where_statement) > 0 and strlen($player_name_statement) > 0) - $where_statement = $where_statement." AND "; - - // add player name condition - $where_statement = $where_statement.$player_name_statement; - - // add subject condition - if(strlen($where_statement) > 0 and strlen($subject_statement) > 0) - $where_statement = $where_statement." AND "; - $where_statement = $where_statement.$subject_statement; - - return $where_statement; -} - -/* -function get_player_record_count($maestro_id, $where_statement, $limit_count) { - global $db_conn; + $range = get_subject_app_range($app_id); + if ($range !== null) { + $where[] = "BR.AppID BETWEEN ? AND ?"; + $types .= "ii"; + $params[] = $range[0]; + $params[] = $range[1]; + } $query = " SELECT COUNT(*) FROM best_record BR, player P, app A - WHERE BR.MaestroID = ? AND ".$where_statement." - "; - - if($limit_count > 0) - $query = $query." LIMIT ".$limit_count; - - // echo $query; - // return; + WHERE " . implode(" AND ", $where); $stmt = $db_conn->prepare($query); - $stmt->bind_param("i", $maestro_id); - $stmt->execute(); - $stmt->bind_result($record_count); - $stmt->fetch(); - $stmt->close(); - - return $record_count; -} -*/ - -function get_player_record_total_count($maestro_id, $where_statement) { - global $db_conn; - - $query = " - SELECT COUNT(*) - FROM best_record BR, player P, app A - WHERE BR.MaestroID = ? AND ".$where_statement." - "; - - // echo $query; - // return; - - $stmt = $db_conn->prepare($query); - $stmt->bind_param("i", $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); $stmt->bind_result($record_count); $stmt->fetch(); @@ -161,38 +75,63 @@ function get_player_record_total_count($maestro_id, $where_statement) { return $record_count; } -function get_player_record_list($maestro_id, $where_statement, $limit_count) { + +function get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count) { global $db_conn; + $where = array( + "BR.MaestroID = ?", + "BR.RecordDateTime >= DATE(?)", + "BR.RecordDateTime < DATE(?) + INTERVAL 1 DAY", + "BR.PlayerID = P.PlayerID", + "BR.AppID = A.AppID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); + + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; + } + + $range = get_subject_app_range($app_id); + if ($range !== null) { + $where[] = "BR.AppID BETWEEN ? AND ?"; + $types .= "ii"; + $params[] = $range[0]; + $params[] = $range[1]; + } + $query = " - SELECT BR.BestRecordID AS Id, DATE(BR.RecordDateTime) AS Date, TIME(BR.RecordDateTime) AS Time, - P.Name AS Name, A.KoreanName AS Subject, BR.BestRecord AS Record + SELECT BR.BestRecordID, DATE(BR.RecordDateTime), TIME(BR.RecordDateTime), + P.Name, A.KoreanName, BR.BestRecord FROM best_record BR, player P, app A - WHERE BR.MaestroID = ? AND ".$where_statement." - ORDER BY BR.RecordDateTime DESC - "; + WHERE " . implode(" AND ", $where) . " + ORDER BY BR.RecordDateTime DESC"; - if($limit_count > 0) - $query = $query." LIMIT ".$limit_count; - - // echo $query; - // return; + if ($limit_count > 0) { + $query .= " LIMIT ?"; + $types .= "i"; + $params[] = $limit_count; + } $stmt = $db_conn->prepare($query); - $stmt->bind_param('i', $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); - $stmt->bind_result($id, $date, $time, $player_name, $subject, $record); + $stmt->bind_result($id, $date, $time, $player_name_result, $subject, $record); $record_array = array(); while($stmt->fetch()) { - $record_date['Id'] = $id; - $record_date['Date'] = $date; - $record_date['Time'] = $time; - $record_date['PlayerName'] = $player_name; - $record_date['Subject'] = $subject; - $record_date['Record'] = $record; - array_push($record_array, $record_date); + $record_date['Id'] = $id; + $record_date['Date'] = $date; + $record_date['Time'] = $time; + $record_date['PlayerName'] = $player_name_result; + $record_date['Subject'] = $subject; + $record_date['Record'] = $record; + array_push($record_array, $record_date); } + $stmt->close(); return $record_array; } diff --git a/src/web/server/record/request_license_timer_player_record_list.php b/src/web/server/record/request_license_timer_player_record_list.php index 1feded3..0b3c794 100644 --- a/src/web/server/record/request_license_timer_player_record_list.php +++ b/src/web/server/record/request_license_timer_player_record_list.php @@ -5,36 +5,15 @@ include "./../lib/send_reply_json.php"; include "./../lib/maestro_account_info.php"; include "./../setup/connect_db.php"; -$maestro_id = $_POST["MaestroID"]; +$maestro_id = (int)$_POST["MaestroID"]; $start_date = $_POST["StartDate"]; $end_date = $_POST["EndDate"]; -$player_name_list = $_POST["PlayerNameList"]; -$app_id = $_POST["AppID"]; -$limit_count = $_POST["LimitCount"]; +$player_name = trim($_POST["PlayerNameList"]); +$app_id = (int)$_POST["AppID"]; +$limit_count = (int)$_POST["LimitCount"]; -// echo "start_date : ".$start_date."\n"; -// echo "end_date : ".$end_date."\n"; -// echo "player_name_list : ".$player_name_list."\n"; -// echo "app_id : ".$app_id."\n"; -// echo "limit_count : ".$limit_count."\n"; -// echo "\n"; - -$date_statement = makeDateStatement($start_date, $end_date); -$player_name_statement = makePlayerNameStatement($player_name_list); -$subject_statement = makeSubjectSentence($app_id); -// echo "maestro : ".$maestro_id."\n"; -// echo "date : ".$date_statement."\n"; -// echo "player_name : ".$player_name_statement."\n"; -// echo "subject : ".$subject_statement."\n"; -$where_statement = make_where_statement($date_statement, $player_name_statement, $subject_statement); - -$recordTotalCount = get_player_record_total_count($maestro_id, $where_statement); -$replyJSON = get_player_record_list($maestro_id, $where_statement, $limit_count); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "오늘의 랭킹 기록 없음"); - $db_conn->close(); - exit; -} +$recordTotalCount = get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id); +$replyJSON = get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count); set_data("RecordTotalCount", $recordTotalCount); set_data("RecordList", $replyJSON); @@ -42,119 +21,52 @@ send_result_success(); exit; - - -function makeDateStatement($start_date, $end_date) { - return " '".$start_date."' < LS.ScoreDateTime AND LS.ScoreDateTime < '".$end_date."'"; - // return " '2017-01-01' < LS.ScoreDateTime AND LS.ScoreDateTime < '".$end_date."'"; +function get_subject_app_range($app_id) { + switch ($app_id) { + case 0: return null; + case 1000: return array(1, 8); + case 1001: return array(11, 18); + case 1002: return array(21, 30); + case 1003: return array(31, 40); + case 3000: return null; + default: return array($app_id, $app_id); + } } -function makeSubjectSentence($app_id) { - if($app_id == 0) - return ""; - else if($app_id == 3000) - return ""; - $subject_statement = ""; - if(is_subject_bundle_item($app_id)) { - switch($app_id) { - case 1000: // korean practice - $subject_statement = $subject_statement."A.AppID < 9"; - break; +function get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id) { + global $db_conn; - case 1001: // english practice - $subject_statement = $subject_statement."10 < A.AppID AND A.AppID < 19"; - break; + $where = array( + "LS.MaestroID = ?", + "LS.ScoreDateTime >= DATE(?)", + "LS.ScoreDateTime < DATE(?) + INTERVAL 1 DAY", + "LS.PlayerID = P.PlayerID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); - case 1002: // korean test - $subject_statement = $subject_statement."20 < A.AppID AND A.AppID < 31"; - break; - - case 1003: // english test - $subject_statement = $subject_statement."30 < A.AppID AND A.AppID < 41"; - break; - } - } else { - $subject_statement = $subject_statement."A.AppID = ".$app_id; + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; } - return $subject_statement." AND LS.AppID = A.AppID"; -} - -function makePlayerNameStatement($player_name_list) { - $player_name_statement = "LS.PlayerID = P.PlayerID"; - if(strlen($player_name_list) > 0) - $player_name_statement = $player_name_statement." AND P.Name LIKE '%".$player_name_list."%'"; - - return $player_name_statement; -} - - -function is_subject_bundle_item($app_id) { - if($app_id >= 1000) - return true; - - return false; -} - -function make_where_statement($date_statement, $player_name_statement, $subject_statement) { - // add date condition - $where_statement = $date_statement; - if(strlen($where_statement) > 0 and strlen($player_name_statement) > 0) - $where_statement = $where_statement." AND "; - - // add player name condition - $where_statement = $where_statement.$player_name_statement; - - // add subject condition - if(strlen($where_statement) > 0 and strlen($subject_statement) > 0) - $where_statement = $where_statement." AND "; - $where_statement = $where_statement.$subject_statement; - - return $where_statement; -} - -/* -function get_player_record_count($maestro_id, $where_statement, $limit_count) { - global $db_conn; + $range = get_subject_app_range($app_id); + if ($range !== null) { + $where[] = "LS.AppID BETWEEN ? AND ?"; + $types .= "ii"; + $params[] = $range[0]; + $params[] = $range[1]; + } $query = " SELECT COUNT(*) FROM license_score LS, player P - WHERE LS.MaestroID = ? AND ".$where_statement." - "; - - if($limit_count > 0) - $query = $query." LIMIT ".$limit_count; - - // echo $query; - // return; + WHERE " . implode(" AND ", $where); $stmt = $db_conn->prepare($query); - $stmt->bind_param("i", $maestro_id); - $stmt->execute(); - $stmt->bind_result($record_count); - $stmt->fetch(); - $stmt->close(); - - return $record_count; -} -*/ - -function get_player_record_total_count($maestro_id, $where_statement) { - global $db_conn; - - $query = " - SELECT COUNT(*) - FROM license_score LS, player P - WHERE LS.MaestroID = ? AND ".$where_statement." - "; - - // echo $query; - // return; - - $stmt = $db_conn->prepare($query); - $stmt->bind_param("i", $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); $stmt->bind_result($record_count); $stmt->fetch(); @@ -163,38 +75,62 @@ function get_player_record_total_count($maestro_id, $where_statement) { return $record_count; } -function get_player_record_list($maestro_id, $where_statement, $limit_count) { + +function get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count) { global $db_conn; + $where = array( + "LS.MaestroID = ?", + "LS.ScoreDateTime >= DATE(?)", + "LS.ScoreDateTime < DATE(?) + INTERVAL 1 DAY", + "LS.PlayerID = P.PlayerID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); + + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; + } + + $range = get_subject_app_range($app_id); + if ($range !== null) { + $where[] = "LS.AppID BETWEEN ? AND ?"; + $types .= "ii"; + $params[] = $range[0]; + $params[] = $range[1]; + } + $query = " - SELECT LS.LicenseScoreID AS ID, DATE(LS.ScoreDateTime) AS Date, TIME(LS.ScoreDateTime) AS Time, - P.Name AS Name, LS.SubjectName AS Subject, LS.Score AS Record + SELECT LS.LicenseScoreID, DATE(LS.ScoreDateTime), TIME(LS.ScoreDateTime), + P.Name, LS.SubjectName, LS.Score FROM license_score LS, player P - WHERE LS.MaestroID = ? AND ".$where_statement." - ORDER BY LS.ScoreDateTime DESC - "; + WHERE " . implode(" AND ", $where) . " + ORDER BY LS.ScoreDateTime DESC"; - if($limit_count > 0) - $query = $query." LIMIT ".$limit_count; - - // echo $query; - // return; + if ($limit_count > 0) { + $query .= " LIMIT ?"; + $types .= "i"; + $params[] = $limit_count; + } $stmt = $db_conn->prepare($query); - $stmt->bind_param('i', $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); - $stmt->bind_result($id, $date, $time, $player_name, $subject, $record); + $stmt->bind_result($id, $date, $time, $player_name_result, $subject, $record); $record_array = array(); while($stmt->fetch()) { - $record_date['Id'] = $id; - $record_date['Date'] = $date; - $record_date['Time'] = $time; - $record_date['PlayerName'] = $player_name; - $record_date['Subject'] = $subject; - $record_date['Record'] = $record; - array_push($record_array, $record_date); + $record_date['Id'] = $id; + $record_date['Date'] = $date; + $record_date['Time'] = $time; + $record_date['PlayerName'] = $player_name_result; + $record_date['Subject'] = $subject; + $record_date['Record'] = $record; + array_push($record_array, $record_date); } + $stmt->close(); return $record_array; } diff --git a/src/web/server/record/request_writing_player_record_list.php b/src/web/server/record/request_writing_player_record_list.php index 37c36ef..ece37c6 100644 --- a/src/web/server/record/request_writing_player_record_list.php +++ b/src/web/server/record/request_writing_player_record_list.php @@ -5,37 +5,15 @@ include "./../lib/send_reply_json.php"; include "./../lib/maestro_account_info.php"; include "./../setup/connect_db.php"; -$maestro_id = $_POST["MaestroID"]; +$maestro_id = (int)$_POST["MaestroID"]; $start_date = $_POST["StartDate"]; $end_date = $_POST["EndDate"]; -$player_name_list = $_POST["PlayerNameList"]; -$app_id = $_POST["AppID"]; -$limit_count = $_POST["LimitCount"]; -// echo "start_date : ".$start_date."\n"; -// echo "end_date : ".$end_date."\n"; -// echo "player_name_list : ".$player_name_list."\n"; -// echo "app_id : ".$app_id."\n"; -// echo "limit_count : ".$limit_count."\n"; -// echo "\n"; +$player_name = trim($_POST["PlayerNameList"]); +$app_id = (int)$_POST["AppID"]; +$limit_count = (int)$_POST["LimitCount"]; -$date_statement = makeDateStatement($start_date, $end_date); -$player_name_statement = makePlayerNameStatement($player_name_list); -$subject_statement = makeSubjectSentence($app_id); -$where_statement = make_where_statement($date_statement, $player_name_statement, $subject_statement); -// echo "maestro : ".$maestro_id."\n"; -// echo "date : ".$date_statement."\n"; -// echo "player_name : ".$player_name_statement."\n"; -// echo "subject : ".$subject_statement."\n"; -// echo "where_statement : ".$where_statement."\n"; - -$recordTotalCount = get_player_record_total_count($maestro_id, $where_statement); -// echo "recordTotalCount : ".$recordTotalCount."\n"; -$replyJSON = get_player_record_list($maestro_id, $where_statement, $limit_count); -if($replyJSON.length === 0) { - send_error_message($replyJSON, "오늘의 랭킹 기록 없음"); - $db_conn->close(); - exit; -} +$recordTotalCount = get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id); +$replyJSON = get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count); set_data("RecordTotalCount", $recordTotalCount); set_data("RecordList", $replyJSON); @@ -43,84 +21,48 @@ send_result_success(); exit; +function get_player_record_total_count($maestro_id, $start_date, $end_date, $player_name, $app_id) { + global $db_conn; + $where = array( + "TE.MaestroID = ?", + "TE.RecordDateTime >= DATE(?)", + "TE.RecordDateTime < DATE(?) + INTERVAL 1 DAY", + "TE.PlayerID = P.PlayerID", + "TE.WritingID = WR.WritingID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); -function makeDateStatement($start_date, $end_date) { - return " '".$start_date."' <= TE.RecordDateTime AND TE.RecordDateTime <= DATE_ADD('".$end_date."', INTERVAL 1 DAY)"; - // return " '".$start_date."' <= TE.RecordDateTime AND TE.RecordDateTime <= '".$end_date."'"; - // return " '2017-01-01' < TE.RecordDateTime"; - // return " '2017-01-01' < TE.RecordDateTime AND TE.RecordDateTime < '".$end_date."'"; -} - -function makeSubjectSentence($app_id) { - if($app_id == 0) - return ""; - - $subject_statement = ""; - if(is_subject_bundle_item($app_id)) { - switch($app_id) { - case 1004: // korean exam - $subject_statement = $subject_statement."WR.Language='korean'"; - break; - - case 1005: // english exam - $subject_statement = $subject_statement."WR.Language='english'"; - break; - } - } else { - $subject_statement = $subject_statement."WR.WritingID=".$app_id; + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; } - return $subject_statement." AND TE.WritingID=WR.WritingID"; -} - -function makePlayerNameStatement($player_name_list) { - $player_name_statement = "TE.PlayerID=P.PlayerID"; - if(strlen($player_name_list) > 0) - $player_name_statement = $player_name_statement." AND P.Name LIKE '%".$player_name_list."%'"; - - return $player_name_statement; -} - - -function is_subject_bundle_item($app_id) { - if($app_id >= 1000) - return true; - - return false; -} - -function make_where_statement($date_statement, $player_name_statement, $subject_statement) { - // add date condition - $where_statement = $date_statement; - if(strlen($where_statement) > 0 and strlen($player_name_statement) > 0) - $where_statement = $where_statement." AND "; - - // add player name condition - $where_statement = $where_statement.$player_name_statement; - - // add subject condition - if(strlen($where_statement) > 0 and strlen($subject_statement) > 0) - $where_statement = $where_statement." AND "; - $where_statement = $where_statement.$subject_statement; - - return $where_statement; -} - -function get_player_record_total_count($maestro_id, $where_statement) { - global $db_conn; + if ($app_id > 0) { + if ($app_id == 1004) { + $where[] = "WR.Language = ?"; + $types .= "s"; + $params[] = "korean"; + } else if ($app_id == 1005) { + $where[] = "WR.Language = ?"; + $types .= "s"; + $params[] = "english"; + } else { + $where[] = "WR.WritingID = ?"; + $types .= "i"; + $params[] = $app_id; + } + } $query = " SELECT COUNT(*) FROM typing_exam_record TE, player P, writing WR - WHERE TE.MaestroID = ? AND ".$where_statement." - "; - - // echo $query; - // return; + WHERE " . implode(" AND ", $where); $stmt = $db_conn->prepare($query); - $stmt->bind_param("i", $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); $stmt->bind_result($record_count); $stmt->fetch(); @@ -129,38 +71,71 @@ function get_player_record_total_count($maestro_id, $where_statement) { return $record_count; } -function get_player_record_list($maestro_id, $where_statement, $limit_count) { + +function get_player_record_list($maestro_id, $start_date, $end_date, $player_name, $app_id, $limit_count) { global $db_conn; + $where = array( + "TE.MaestroID = ?", + "TE.RecordDateTime >= DATE(?)", + "TE.RecordDateTime < DATE(?) + INTERVAL 1 DAY", + "TE.PlayerID = P.PlayerID", + "TE.WritingID = WR.WritingID", + ); + $types = "iss"; + $params = array($maestro_id, $start_date, $end_date); + + if (strlen($player_name) > 0) { + $where[] = "P.Name LIKE ?"; + $types .= "s"; + $params[] = "%" . $player_name . "%"; + } + + if ($app_id > 0) { + if ($app_id == 1004) { + $where[] = "WR.Language = ?"; + $types .= "s"; + $params[] = "korean"; + } else if ($app_id == 1005) { + $where[] = "WR.Language = ?"; + $types .= "s"; + $params[] = "english"; + } else { + $where[] = "WR.WritingID = ?"; + $types .= "i"; + $params[] = $app_id; + } + } + $query = " - SELECT TE.TypingExamRecordID as Id, DATE(TE.RecordDateTime) AS Date, TIME(TE.RecordDateTime) AS Time, - P.Name AS Name, WR.Name AS Subject, TE.Record AS Record + SELECT TE.TypingExamRecordID, DATE(TE.RecordDateTime), TIME(TE.RecordDateTime), + P.Name, WR.Name, TE.Record FROM typing_exam_record TE, player P, writing WR - WHERE TE.MaestroID = ? AND TE.WritingID=WR.WritingID AND TE.PlayerID=P.PlayerID AND ".$where_statement." - ORDER BY TE.RecordDateTime DESC - "; + WHERE " . implode(" AND ", $where) . " + ORDER BY TE.RecordDateTime DESC"; - if($limit_count > 0) - $query = $query." LIMIT ".$limit_count; - - // echo $query; - // return; + if ($limit_count > 0) { + $query .= " LIMIT ?"; + $types .= "i"; + $params[] = $limit_count; + } $stmt = $db_conn->prepare($query); - $stmt->bind_param('i', $maestro_id); + $stmt->bind_param($types, ...$params); $stmt->execute(); - $stmt->bind_result($id, $date, $time, $player_name, $subject, $record); + $stmt->bind_result($id, $date, $time, $player_name_result, $subject, $record); $record_array = array(); while($stmt->fetch()) { - $record_date['Id'] = $id; - $record_date['Date'] = $date; - $record_date['Time'] = $time; - $record_date['PlayerName'] = $player_name; - $record_date['Subject'] = $subject; - $record_date['Record'] = $record; - array_push($record_array, $record_date); + $record_date['Id'] = $id; + $record_date['Date'] = $date; + $record_date['Time'] = $time; + $record_date['PlayerName'] = $player_name_result; + $record_date['Subject'] = $subject; + $record_date['Record'] = $record; + array_push($record_array, $record_date); } + $stmt->close(); return $record_array; }