GitHub Actions commited on
Commit
c0c176a
Β·
1 Parent(s): 8bd258a

Auto-deploy from GitHub Actions - 2025-12-17 02:43:31

Browse files
app/__init__.py CHANGED
@@ -131,6 +131,10 @@ def create_app() -> Flask:
131
  def _is_postgres_uri(uri: str) -> bool:
132
  return uri.startswith('postgresql://') or uri.startswith('postgres://')
133
 
 
 
 
 
134
  def _normalize_postgres_uri(uri: str) -> str:
135
  """
136
  - postgres:// -> postgresql:// 둜 μ •κ·œν™”
@@ -159,8 +163,13 @@ def create_app() -> Flask:
159
  new_query = urlencode(q) if q else ''
160
  return urlunparse(parsed._replace(query=new_query))
161
 
162
- # NOTE(운영): μ‹€μ„œλ²„ ν˜Όμ„  λ°©μ§€λ₯Ό μœ„ν•΄ SQLite 폴백은 μ‚¬μš©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
163
- # DB μž₯μ• /μ§€μ—° μ‹œμ—λŠ” 항상 503 μ•ˆλ‚΄ νŽ˜μ΄μ§€λ‘œ μœ λ„ν•©λ‹ˆλ‹€.
 
 
 
 
 
164
 
165
  def _retry_sleep_seconds(attempt_idx: int) -> float:
166
  """
@@ -214,11 +223,20 @@ def create_app() -> Flask:
214
  # SQLite 폴백은 ν•˜μ§€ μ•Šκ³ , μ„œλ²„λŠ” 살렀두고 μ—λŸ¬ νŽ˜μ΄μ§€λ‘œ μ•ˆλ‚΄
215
  _mark_db_unavailable(last_err, phase="connect_test")
216
  else:
217
- # μ™ΈλΆ€ DB(DATABASE_URL)κ°€ μ—†κ±°λ‚˜ postgres ν˜•μ‹μ΄ μ•„λ‹Œ 경우: μ‹€μ„œλ²„μ—μ„œλŠ” DB μ‚¬μš© λΆˆκ°€λ‘œ κ°„μ£Ό
218
- _mark_db_unavailable(
219
- ValueError("DATABASE_URL이 μ—†κ±°λ‚˜ PostgreSQL ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€. (μš΄μ˜μ—μ„œλŠ” SQLite μ‚¬μš© κΈˆμ§€)"),
220
- phase="invalid_db_uri",
221
- )
 
 
 
 
 
 
 
 
 
222
 
223
  app.config['SQLALCHEMY_ENGINE_OPTIONS'] = engine_options
224
 
 
131
  def _is_postgres_uri(uri: str) -> bool:
132
  return uri.startswith('postgresql://') or uri.startswith('postgres://')
133
 
134
+ def _is_sqlite_uri(uri: str) -> bool:
135
+ # flask-sqlalchemy/sqlalchemyμ—μ„œ ν”νžˆ μ“°λŠ” sqlite URI ν˜•μ‹λ“€ ν—ˆμš©
136
+ return uri.startswith('sqlite:///') or uri.startswith('sqlite://')
137
+
138
  def _normalize_postgres_uri(uri: str) -> str:
139
  """
140
  - postgres:// -> postgresql:// 둜 μ •κ·œν™”
 
163
  new_query = urlencode(q) if q else ''
164
  return urlunparse(parsed._replace(query=new_query))
165
 
166
+ # NOTE:
167
+ # - 둜컬/개발 ν™˜κ²½μ—μ„œλŠ” Config κΈ°λ³Έκ°’(SQLite)을 정상 ν—ˆμš©ν•©λ‹ˆλ‹€.
168
+ # - μš΄μ˜μ—μ„œ "PostgreSQL만 κ°•μ œ"ν•˜κ³  μ‹Άλ‹€λ©΄ ν™˜κ²½λ³€μˆ˜λ‘œ strict λͺ¨λ“œλ₯Ό μΌ­λ‹ˆλ‹€.
169
+ # (예: REQUIRE_POSTGRES=1)
170
+ def _require_postgres_only() -> bool:
171
+ v = (os.environ.get('REQUIRE_POSTGRES') or os.environ.get('DB_STRICT_MODE') or '').strip().lower()
172
+ return v in ('1', 'true', 'yes', 'y', 'on')
173
 
174
  def _retry_sleep_seconds(attempt_idx: int) -> float:
175
  """
 
223
  # SQLite 폴백은 ν•˜μ§€ μ•Šκ³ , μ„œλ²„λŠ” 살렀두고 μ—λŸ¬ νŽ˜μ΄μ§€λ‘œ μ•ˆλ‚΄
224
  _mark_db_unavailable(last_err, phase="connect_test")
225
  else:
226
+ # SQLiteλŠ” 둜컬 κΈ°λ³Έκ°’μœΌλ‘œ ν—ˆμš© (strict λͺ¨λ“œκ°€ μ•„λ‹ˆλ©΄ 정상 λΆ€νŒ…)
227
+ if _is_sqlite_uri(effective_db_uri) and not _require_postgres_only():
228
+ try:
229
+ logger.info(f"[λ°μ΄ν„°λ² μ΄μŠ€] SQLite μ‚¬μš©: {effective_db_uri}")
230
+ except Exception:
231
+ # λ‘œκΉ… μ‹€νŒ¨λŠ” λ¬΄μ‹œ (λΆ€νŒ…μ— 영ν–₯ μ£Όμ§€ μ•ŠμŒ)
232
+ pass
233
+ else:
234
+ # μ™ΈλΆ€ DB(DATABASE_URL)κ°€ μ—†κ±°λ‚˜ postgres ν˜•μ‹μ΄ μ•„λ‹Œ 경우:
235
+ # strict λͺ¨λ“œμ—μ„œλŠ” DB μ‚¬μš© λΆˆκ°€λ‘œ κ°„μ£Όν•˜μ—¬ μž₯μ•  λͺ¨λ“œλ‘œ μ „ν™˜
236
+ _mark_db_unavailable(
237
+ ValueError("DATABASE_URL이 μ—†κ±°λ‚˜ PostgreSQL ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€. (REQUIRE_POSTGRES=1이면 SQLite μ‚¬μš© κΈˆμ§€)"),
238
+ phase="invalid_db_uri",
239
+ )
240
 
241
  app.config['SQLALCHEMY_ENGINE_OPTIONS'] = engine_options
242
 
app/database.py CHANGED
@@ -627,3 +627,24 @@ class WebtoonDurationJob(db.Model):
627
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
628
  }
629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
628
  }
629
 
630
+
631
+ class WebtoonStageKeyMapping(db.Model):
632
+ """μž‘μ—…λ‹¨κ³„ ν‚€(예: 02, 03-2) -> ν‘œμ‹œμš© 단계λͺ…(예: 02. μ½˜ν‹°) λ§€ν•‘"""
633
+ __tablename__ = 'webtoon_stage_key_mapping'
634
+
635
+ id = db.Column(db.Integer, primary_key=True)
636
+ stage_key = db.Column(db.String(50), unique=True, nullable=False, index=True)
637
+ label = db.Column(db.String(200), nullable=False)
638
+
639
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
640
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
641
+
642
+ def to_dict(self):
643
+ return {
644
+ 'id': self.id,
645
+ 'stage_key': self.stage_key,
646
+ 'label': self.label,
647
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
648
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
649
+ }
650
+
app/gemini_client.py CHANGED
@@ -19,6 +19,15 @@ def get_gemini_api_key():
19
  if api_key:
20
  print(f"[Gemini] ν™˜κ²½ λ³€μˆ˜μ—μ„œ API ν‚€ κ°€μ Έμ˜΄ (길이: {len(api_key)}자)")
21
  return api_key
 
 
 
 
 
 
 
 
 
22
 
23
  # DBμ—μ„œ κ°€μ Έμ˜€κΈ° (μˆœν™˜ μ°Έμ‘° λ°©μ§€λ₯Ό μœ„ν•΄ μ—¬κΈ°μ„œ μž„ν¬νŠΈ)
24
  try:
@@ -33,8 +42,6 @@ def get_gemini_api_key():
33
  print(f"[Gemini] DBμ—μ„œ API ν‚€ 쑰회 μ‹€νŒ¨: {e}")
34
  return ''
35
 
36
- GEMINI_API_KEY = get_gemini_api_key()
37
-
38
  # μ‚¬μš© κ°€λŠ₯ν•œ Gemini λͺ¨λΈ λͺ©λ‘ (μ΅œμ‹  버전 μš°μ„ )
39
  AVAILABLE_GEMINI_MODELS = [
40
  'gemini-2.0-flash-exp',
 
19
  if api_key:
20
  print(f"[Gemini] ν™˜κ²½ λ³€μˆ˜μ—μ„œ API ν‚€ κ°€μ Έμ˜΄ (길이: {len(api_key)}자)")
21
  return api_key
22
+
23
+ # μ•± μ»¨ν…μŠ€νŠΈ λ°–(λͺ¨λ“ˆ import μ‹œμ /슀크립트 μ‹€ν–‰ λ“±)μ—μ„œλŠ” DB 쑰회λ₯Ό μ‹œλ„ν•˜μ§€ μ•ŠμŒ
24
+ # (SystemConfig.queryκ°€ current_app/app_contextλ₯Ό ν•„μš”λ‘œ ν•˜λ―€λ‘œ)
25
+ try:
26
+ from flask import has_app_context
27
+ if not has_app_context():
28
+ return ''
29
+ except Exception:
30
+ return ''
31
 
32
  # DBμ—μ„œ κ°€μ Έμ˜€κΈ° (μˆœν™˜ μ°Έμ‘° λ°©μ§€λ₯Ό μœ„ν•΄ μ—¬κΈ°μ„œ μž„ν¬νŠΈ)
33
  try:
 
42
  print(f"[Gemini] DBμ—μ„œ API ν‚€ 쑰회 μ‹€νŒ¨: {e}")
43
  return ''
44
 
 
 
45
  # μ‚¬μš© κ°€λŠ₯ν•œ Gemini λͺ¨λΈ λͺ©λ‘ (μ΅œμ‹  버전 μš°μ„ )
46
  AVAILABLE_GEMINI_MODELS = [
47
  'gemini-2.0-flash-exp',
app/routes.py CHANGED
@@ -13,6 +13,7 @@ from app.database import (
13
  WebtoonProjectUpload, WebtoonProjectTask,
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
 
16
  )
17
  from app.vector_db import get_vector_db
18
  from app.gemini_client import get_gemini_client
@@ -64,6 +65,7 @@ def get_default_admin_menu():
64
  {"label": "일정 μ‚°μ •", "endpoint": "main.admin_webtoon_project_schedule"},
65
  {"label": "μ œμž‘ κΈ°κ°„ 톡계/μ‚°μ •", "endpoint": "main.admin_webtoon_planner"},
66
  {"label": "μ œμž‘ 단계 톡계/μ‚°μ •", "endpoint": "main.admin_webtoon_stage_planner"},
 
67
  {"label": "μ›Ήνˆ° WBS", "endpoint": "main.admin_webtoon_wbs"},
68
  ],
69
  },
@@ -2532,6 +2534,34 @@ def admin_webtoon_stage_planner():
2532
  return render_template('admin_webtoon_stage_planner.html')
2533
 
2534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2535
  @main_bp.route('/api/webtoons/project-uploads', methods=['GET'])
2536
  @admin_required
2537
  def list_webtoon_project_uploads():
@@ -4107,6 +4137,76 @@ def get_webtoon_stage_episode_duration_stats():
4107
  return jsonify({'error': f'νšŒμ°¨λ³„ μ œμž‘λ‹¨κ³„ 톡계 쑰회 쀑 였λ₯˜: {str(e)}'}), 500
4108
 
4109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4110
  @main_bp.route('/api/webtoons/duration/stage-plan', methods=['POST'])
4111
  @admin_required
4112
  def plan_webtoon_duration_by_stage():
 
13
  WebtoonProjectUpload, WebtoonProjectTask,
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
16
+ WebtoonStageKeyMapping,
17
  )
18
  from app.vector_db import get_vector_db
19
  from app.gemini_client import get_gemini_client
 
65
  {"label": "일정 μ‚°μ •", "endpoint": "main.admin_webtoon_project_schedule"},
66
  {"label": "μ œμž‘ κΈ°κ°„ 톡계/μ‚°μ •", "endpoint": "main.admin_webtoon_planner"},
67
  {"label": "μ œμž‘ 단계 톡계/μ‚°μ •", "endpoint": "main.admin_webtoon_stage_planner"},
68
+ {"label": "단계킀 관리 ν™”λ©΄", "endpoint": "main.admin_webtoon_stage_keys"},
69
  {"label": "μ›Ήνˆ° WBS", "endpoint": "main.admin_webtoon_wbs"},
70
  ],
71
  },
 
2534
  return render_template('admin_webtoon_stage_planner.html')
2535
 
2536
 
2537
+ @main_bp.route('/admin/webtoons/stage-keys')
2538
+ @admin_required
2539
+ def admin_webtoon_stage_keys():
2540
+ """단계킀(μž‘μ—…λ‹¨κ³„ ν‚€) ν‘œμ‹œλͺ… λ§€ν•‘ 관리"""
2541
+ return render_template('admin_webtoon_stage_keys.html')
2542
+
2543
+
2544
+ # ν˜Έν™˜/μ˜€νƒ€ λ°©μ§€: κΈ°μ‘΄(λ˜λŠ” μ‚¬μš©μžκ°€ ν”νžˆ μž…λ ₯ν•˜λŠ”) λ‹¨μˆ˜ κ²½λ‘œλŠ” 볡수 경둜둜 λ¦¬λ‹€μ΄λ ‰νŠΈ
2545
+ @main_bp.route('/admin/webtoons/stage-key')
2546
+ @admin_required
2547
+ def admin_webtoon_stage_key_redirect():
2548
+ return redirect(url_for('main.admin_webtoon_stage_keys'))
2549
+
2550
+
2551
+ # ν˜Έν™˜/μ˜€νƒ€ λ°©μ§€: keys κ²½λ‘œλ„ stage-keys둜 λ¦¬λ‹€μ΄λ ‰νŠΈ
2552
+ @main_bp.route('/admin/webtoons/keys')
2553
+ @admin_required
2554
+ def admin_webtoon_keys_redirect():
2555
+ return redirect(url_for('main.admin_webtoon_stage_keys'))
2556
+
2557
+
2558
+ # ν˜Έν™˜/μ˜€νƒ€ λ°©μ§€: key κ²½λ‘œλ„ stage-keys둜 λ¦¬λ‹€μ΄λ ‰νŠΈ
2559
+ @main_bp.route('/admin/webtoons/key')
2560
+ @admin_required
2561
+ def admin_webtoon_key_redirect():
2562
+ return redirect(url_for('main.admin_webtoon_stage_keys'))
2563
+
2564
+
2565
  @main_bp.route('/api/webtoons/project-uploads', methods=['GET'])
2566
  @admin_required
2567
  def list_webtoon_project_uploads():
 
4137
  return jsonify({'error': f'νšŒμ°¨λ³„ μ œμž‘λ‹¨κ³„ 톡계 쑰회 쀑 였λ₯˜: {str(e)}'}), 500
4138
 
4139
 
4140
+ @main_bp.route('/api/webtoons/stage-keys', methods=['GET'])
4141
+ @admin_required
4142
+ def list_webtoon_stage_keys():
4143
+ """단계킀 ν‘œμ‹œλͺ… λ§€ν•‘ λͺ©λ‘"""
4144
+ try:
4145
+ rows = WebtoonStageKeyMapping.query.order_by(WebtoonStageKeyMapping.stage_key.asc()).all()
4146
+ return jsonify({'mappings': [r.to_dict() for r in rows]}), 200
4147
+ except Exception as e:
4148
+ return jsonify({'error': f'단계킀 λ§€ν•‘ 쑰회 쀑 였λ₯˜: {str(e)}'}), 500
4149
+
4150
+
4151
+ @main_bp.route('/api/webtoons/stage-keys', methods=['PUT'])
4152
+ @admin_required
4153
+ def upsert_webtoon_stage_keys():
4154
+ """단계킀 ν‘œμ‹œλͺ… λ§€ν•‘ 벌크 μ €μž₯(μ—…μ„œνŠΈ)"""
4155
+ try:
4156
+ payload = request.get_json(silent=True) or {}
4157
+ items = payload.get('mappings') or []
4158
+ if not isinstance(items, list):
4159
+ return jsonify({'error': 'mappingsλŠ” 배열이어야 ν•©λ‹ˆλ‹€.'}), 400
4160
+
4161
+ cleaned = []
4162
+ for it in items:
4163
+ if not isinstance(it, dict):
4164
+ continue
4165
+ k = (str(it.get('stage_key') or '').strip())
4166
+ v = (str(it.get('label') or '').strip())
4167
+ if not k:
4168
+ continue
4169
+ cleaned.append((k, v))
4170
+
4171
+ # 빈 label은 μ‚­μ œλ‘œ μ·¨κΈ‰
4172
+ keys_to_delete = [k for k, v in cleaned if not v]
4173
+ if keys_to_delete:
4174
+ WebtoonStageKeyMapping.query.filter(WebtoonStageKeyMapping.stage_key.in_(keys_to_delete)).delete(synchronize_session=False)
4175
+
4176
+ # upsert
4177
+ for k, v in cleaned:
4178
+ if not v:
4179
+ continue
4180
+ row = WebtoonStageKeyMapping.query.filter_by(stage_key=k).first()
4181
+ if row:
4182
+ row.label = v
4183
+ else:
4184
+ db.session.add(WebtoonStageKeyMapping(stage_key=k, label=v))
4185
+
4186
+ db.session.commit()
4187
+ rows = WebtoonStageKeyMapping.query.order_by(WebtoonStageKeyMapping.stage_key.asc()).all()
4188
+ return jsonify({'message': 'μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'mappings': [r.to_dict() for r in rows]}), 200
4189
+ except Exception as e:
4190
+ db.session.rollback()
4191
+ return jsonify({'error': f'단계킀 λ§€ν•‘ μ €μž₯ 쀑 였λ₯˜: {str(e)}'}), 500
4192
+
4193
+
4194
+ @main_bp.route('/api/webtoons/stage-keys/<path:stage_key>', methods=['DELETE'])
4195
+ @admin_required
4196
+ def delete_webtoon_stage_key(stage_key):
4197
+ """단계킀 λ§€ν•‘ μ‚­μ œ"""
4198
+ try:
4199
+ k = (str(stage_key or '').strip())
4200
+ if not k:
4201
+ return jsonify({'error': 'stage_keyκ°€ ν•„μš”ν•©λ‹ˆλ‹€.'}), 400
4202
+ WebtoonStageKeyMapping.query.filter_by(stage_key=k).delete(synchronize_session=False)
4203
+ db.session.commit()
4204
+ return jsonify({'message': 'μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.'}), 200
4205
+ except Exception as e:
4206
+ db.session.rollback()
4207
+ return jsonify({'error': f'단계킀 λ§€ν•‘ μ‚­μ œ 쀑 였λ₯˜: {str(e)}'}), 500
4208
+
4209
+
4210
  @main_bp.route('/api/webtoons/duration/stage-plan', methods=['POST'])
4211
  @admin_required
4212
  def plan_webtoon_duration_by_stage():
templates/admin_webtoon_planner.html CHANGED
@@ -229,6 +229,8 @@
229
  const startDateEl = document.getElementById('startDate');
230
  const launchDateEl = document.getElementById('launchDate');
231
 
 
 
232
  function showAlert(message, type) {
233
  alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
234
  setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
@@ -246,6 +248,28 @@
246
  return div.innerHTML;
247
  }
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  async function loadStats() {
250
  const { res, data } = await fetchJson('/api/webtoons/duration/stats');
251
  if (!res.ok) {
@@ -286,9 +310,10 @@
286
  const title = (r.top_texts && r.top_texts[0]) ? r.top_texts[0].value : '';
287
  const tasks = (r.top_tasks || []).slice(0, 5).map(x => x.value).join(', ');
288
  const meta = [title, tasks].filter(Boolean).join(' / ');
 
289
  return `
290
  <tr>
291
- <td><strong>${escapeHtml(r.stage_key || '')}</strong></td>
292
  <td>${r.count ?? 0}</td>
293
  <td>${r.recommended_days ?? '-'}</td>
294
  <td>${r.median_days ?? '-'}</td>
@@ -327,7 +352,7 @@
327
  const header = `${e.episode_num}ν™” Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}일)`;
328
  const rows = stages.map(s => `
329
  <tr>
330
- <td style="width:90px;"><strong>${escapeHtml(s.stage_key || '')}</strong></td>
331
  <td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
332
  <td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
333
  <td style="width:90px;">${s.duration_days || 0}</td>
@@ -339,7 +364,7 @@
339
  <div style="margin-top:10px;">
340
  <table>
341
  <thead>
342
- <tr><th style="width:90px;">단계킀</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
343
  </thead>
344
  <tbody>${rows}</tbody>
345
  </table>
@@ -389,8 +414,13 @@
389
  showAlert('역산이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
390
  }
391
 
392
- loadStats();
393
- basisEl.addEventListener('change', () => loadStats());
 
 
 
 
 
394
  </script>
395
  </body>
396
  </html>
 
229
  const startDateEl = document.getElementById('startDate');
230
  const launchDateEl = document.getElementById('launchDate');
231
 
232
+ let stageKeyMap = {};
233
+
234
  function showAlert(message, type) {
235
  alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
236
  setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
 
248
  return div.innerHTML;
249
  }
250
 
251
+ async function loadStageKeyMap() {
252
+ const { res, data } = await fetchJson('/api/webtoons/stage-keys');
253
+ if (!res.ok) {
254
+ stageKeyMap = {};
255
+ return;
256
+ }
257
+ const items = data?.mappings || [];
258
+ stageKeyMap = {};
259
+ for (const it of items) {
260
+ const k = String(it?.stage_key || '').trim();
261
+ const v = String(it?.label || '').trim();
262
+ if (k && v) stageKeyMap[k] = v;
263
+ }
264
+ }
265
+
266
+ function displayStageLabel(stageKey, fallbackText = '') {
267
+ const k = String(stageKey || '').trim();
268
+ const mapped = stageKeyMap[k];
269
+ if (mapped) return mapped;
270
+ return k || (fallbackText ? String(fallbackText) : '');
271
+ }
272
+
273
  async function loadStats() {
274
  const { res, data } = await fetchJson('/api/webtoons/duration/stats');
275
  if (!res.ok) {
 
310
  const title = (r.top_texts && r.top_texts[0]) ? r.top_texts[0].value : '';
311
  const tasks = (r.top_tasks || []).slice(0, 5).map(x => x.value).join(', ');
312
  const meta = [title, tasks].filter(Boolean).join(' / ');
313
+ const label = displayStageLabel(r.stage_key, title);
314
  return `
315
  <tr>
316
+ <td><strong title="${escapeHtml(r.stage_key || '')}">${escapeHtml(label || '')}</strong></td>
317
  <td>${r.count ?? 0}</td>
318
  <td>${r.recommended_days ?? '-'}</td>
319
  <td>${r.median_days ?? '-'}</td>
 
352
  const header = `${e.episode_num}ν™” Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}일)`;
353
  const rows = stages.map(s => `
354
  <tr>
355
+ <td style="width:90px;"><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key, ''))}</strong></td>
356
  <td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
357
  <td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
358
  <td style="width:90px;">${s.duration_days || 0}</td>
 
364
  <div style="margin-top:10px;">
365
  <table>
366
  <thead>
367
+ <tr><th style="width:90px;">단계</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
368
  </thead>
369
  <tbody>${rows}</tbody>
370
  </table>
 
414
  showAlert('역산이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
415
  }
416
 
417
+ async function init() {
418
+ await loadStageKeyMap();
419
+ await loadStats();
420
+ basisEl.addEventListener('change', () => loadStats());
421
+ }
422
+
423
+ init();
424
  </script>
425
  </body>
426
  </html>
templates/admin_webtoon_stage_keys.html ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>단계킀 관리 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root{
11
+ --bg:#f6f7fb;--card:#fff;--text:#202124;--muted:#5f6368;--border:#e5e7eb;--border-strong:#dadce0;
12
+ --primary:#1a73e8;--primary-hover:#1557b0;--shadow:0 10px 24px rgba(17,24,39,.08);--radius:12px;
13
+ --danger:#c5221f;
14
+ }
15
+ *{margin:0;padding:0;box-sizing:border-box}
16
+ body{
17
+ font-family:'Inter', -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
18
+ background: radial-gradient(1200px 600px at 20% -10%, rgba(26,115,232,0.10), rgba(255,255,255,0)) , var(--bg);
19
+ color:var(--text);overflow-x:hidden
20
+ }
21
+ .container{max-width:1180px;margin:22px auto;padding:0 24px}
22
+ .page-header{margin:8px 0 14px}
23
+ .page-header h1{font-size:18px;font-weight:700;letter-spacing:-.2px}
24
+ .page-header p{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.6}
25
+
26
+ .card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);margin-bottom:14px}
27
+ .card-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:linear-gradient(to bottom,#fff,#fbfbff)}
28
+ .card-title{font-size:15px;font-weight:700}
29
+ .card-body{padding:16px}
30
+ .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
31
+ .btn{padding:10px 12px;border:1px solid var(--border);border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;background:#fff;color:var(--text)}
32
+ .btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}
33
+ .btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}
34
+ .btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
35
+ .btn-danger:hover{filter:brightness(.95)}
36
+ .btn:disabled{opacity:.55;cursor:not-allowed}
37
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:12px}
38
+ .alert{margin-bottom:12px;padding:10px 12px;border-radius:10px;font-size:13px;border:1px solid transparent}
39
+ .alert.success{background:#e6f4ea;border-color:#b7dfc0;color:#137333}
40
+ .alert.error{background:#fce8e6;border-color:#f6aea9;color:#c5221f}
41
+ .hint{color:var(--muted);font-size:12px;line-height:1.5}
42
+
43
+ table{width:100%;border-collapse:collapse}
44
+ thead th{text-align:left;font-size:12px;color:var(--muted);padding:10px 8px;border-bottom:1px solid var(--border);background:#fbfbff}
45
+ tbody td{font-size:13px;padding:10px 8px;border-bottom:1px solid var(--border);vertical-align:middle}
46
+ tbody tr:hover{background:#fafbff}
47
+ .input{
48
+ width:100%;
49
+ padding:10px 12px;border:1px solid var(--border-strong);border-radius:10px;font-size:13px;outline:none;background:#fff;
50
+ }
51
+ .input[readonly]{background:#f7f7fb;color:var(--muted)}
52
+ </style>
53
+ </head>
54
+ <body>
55
+ {% set admin_nav_title = 'μ›Ήνˆ°' %}
56
+ {% set admin_nav_icon = 'πŸ—‚οΈ' %}
57
+ {% include '_admin_nav.html' %}
58
+
59
+ <div class="container">
60
+ <div class="page-header">
61
+ <h1>단계킀 관리</h1>
62
+ <p>μž‘μ—…λ‹¨κ³„ ν‚€(예: <span class="mono">02</span>, <span class="mono">03-2</span>)λ₯Ό ν‘œμ‹œμš© 단계λͺ…(예: <span class="mono">02. μ½˜ν‹°</span>)으둜 λ§€ν•‘ν•©λ‹ˆλ‹€. 이 섀정은 <strong>μ œμž‘ κΈ°κ°„ 톡계/μ‚°μ •</strong>, <strong>μ œμž‘ 단계 톡계/μ‚°μ •</strong> λ“± 단계킀 ν‘œμ‹œ 화면에 μ μš©λ©λ‹ˆλ‹€.</p>
63
+ </div>
64
+
65
+ <div id="alertContainer"></div>
66
+
67
+ <div class="card">
68
+ <div class="card-header">
69
+ <div class="card-title">λ§€ν•‘ λͺ©λ‘</div>
70
+ <div class="row">
71
+ <button class="btn" type="button" onclick="reload()">μƒˆλ‘œκ³ μΉ¨</button>
72
+ <button class="btn" type="button" onclick="addRow()">μΆ”κ°€</button>
73
+ <button id="saveBtn" class="btn btn-primary" type="button" onclick="saveAll()">μ €μž₯</button>
74
+ </div>
75
+ </div>
76
+ <div class="card-body">
77
+ <table>
78
+ <thead>
79
+ <tr>
80
+ <th style="width:200px;">단계킀</th>
81
+ <th>ν‘œμ‹œλͺ…</th>
82
+ <th style="width:160px;">μ—…λ°μ΄νŠΈ</th>
83
+ <th style="width:110px;">μ‚­μ œ</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody id="tbody">
87
+ <tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">λΆˆλŸ¬μ˜€λŠ” 쀑...</td></tr>
88
+ </tbody>
89
+ </table>
90
+ <div class="hint" style="margin-top:10px;">
91
+ - <strong>μ €μž₯</strong>은 전체 μ—…μ„œνŠΈ(μΆ”κ°€/μˆ˜μ •)μž…λ‹ˆλ‹€.<br>
92
+ - ν‘œμ‹œλͺ…을 λΉ„μš°κ³  μ €μž₯ν•˜λ©΄ ν•΄λ‹Ή 단계킀 맀핑은 μ‚­μ œλ©λ‹ˆλ‹€.
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <script>
99
+ const alertContainer = document.getElementById('alertContainer');
100
+ const tbody = document.getElementById('tbody');
101
+ const saveBtn = document.getElementById('saveBtn');
102
+
103
+ function showAlert(message, type) {
104
+ alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
105
+ setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
106
+ }
107
+ async function fetchJson(url, options = {}) {
108
+ const res = await fetch(url, { credentials: 'include', ...options });
109
+ const text = await res.text();
110
+ let data = null;
111
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
112
+ return { res, data };
113
+ }
114
+ function escapeHtml(text) {
115
+ const div = document.createElement('div');
116
+ div.textContent = text == null ? '' : String(text);
117
+ return div.innerHTML;
118
+ }
119
+
120
+ function rowTemplate(item, { isNew } = { isNew: false }) {
121
+ const k = item?.stage_key || '';
122
+ const v = item?.label || '';
123
+ const updatedAt = item?.updated_at || '';
124
+ return `
125
+ <tr data-new="${isNew ? '1' : '0'}" data-key="${escapeHtml(k)}">
126
+ <td>
127
+ <input class="input mono js-key" ${isNew ? '' : 'readonly'} placeholder="예: 02 λ˜λŠ” 03-2" value="${escapeHtml(k)}" />
128
+ </td>
129
+ <td>
130
+ <input class="input js-label" placeholder="예: 02. μ½˜ν‹°" value="${escapeHtml(v)}" />
131
+ </td>
132
+ <td class="mono" style="color:var(--muted)">${escapeHtml(updatedAt)}</td>
133
+ <td>
134
+ <button class="btn btn-danger" type="button" onclick="deleteRow(this)">μ‚­μ œ</button>
135
+ </td>
136
+ </tr>
137
+ `;
138
+ }
139
+
140
+ async function reload() {
141
+ const { res, data } = await fetchJson('/api/webtoons/stage-keys');
142
+ if (!res.ok) {
143
+ showAlert(data?.error || `쑰회 μ‹€νŒ¨ (${res.status})`, 'error');
144
+ tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">쑰회 μ‹€νŒ¨</td></tr>`;
145
+ return;
146
+ }
147
+ const items = data?.mappings || [];
148
+ if (!items.length) {
149
+ tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">맀핑이 μ—†μŠ΅λ‹ˆλ‹€. β€œμΆ”κ°€β€λ‘œ λ“±λ‘ν•˜μ„Έμš”.</td></tr>`;
150
+ return;
151
+ }
152
+ tbody.innerHTML = items.map(it => rowTemplate(it, { isNew: false })).join('');
153
+ }
154
+
155
+ function addRow() {
156
+ const html = rowTemplate({ stage_key: '', label: '' }, { isNew: true });
157
+ if (tbody.querySelector('tr td[colspan]')) {
158
+ tbody.innerHTML = html;
159
+ } else {
160
+ tbody.insertAdjacentHTML('beforeend', html);
161
+ }
162
+ // μƒˆ ν–‰μ˜ key μž…λ ₯에 포컀슀
163
+ const last = tbody.querySelector('tr:last-child .js-key');
164
+ if (last) last.focus();
165
+ }
166
+
167
+ function collectMappings() {
168
+ const rows = Array.from(tbody.querySelectorAll('tr')).filter(tr => !tr.querySelector('td[colspan]'));
169
+ const out = [];
170
+ for (const tr of rows) {
171
+ const k = (tr.querySelector('.js-key')?.value || '').trim();
172
+ const v = (tr.querySelector('.js-label')?.value || '').trim();
173
+ if (!k) continue;
174
+ out.push({ stage_key: k, label: v });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ async function saveAll() {
180
+ const mappings = collectMappings();
181
+ saveBtn.disabled = true;
182
+ const { res, data } = await fetchJson('/api/webtoons/stage-keys', {
183
+ method: 'PUT',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({ mappings })
186
+ });
187
+ saveBtn.disabled = false;
188
+ if (!res.ok) {
189
+ showAlert(data?.error || `μ €μž₯ μ‹€νŒ¨ (${res.status})`, 'error');
190
+ return;
191
+ }
192
+ showAlert('μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
193
+ await reload();
194
+ }
195
+
196
+ async function deleteRow(btn) {
197
+ const tr = btn.closest('tr');
198
+ const isNew = (tr?.getAttribute('data-new') === '1');
199
+ const key = (tr?.querySelector('.js-key')?.value || '').trim();
200
+ if (isNew) {
201
+ tr.remove();
202
+ return;
203
+ }
204
+ if (!key) {
205
+ tr.remove();
206
+ return;
207
+ }
208
+ if (!confirm(`'${key}' 맀핑을 μ‚­μ œν• κΉŒμš”?`)) return;
209
+ const { res, data } = await fetchJson(`/api/webtoons/stage-keys/${encodeURIComponent(key)}`, { method: 'DELETE' });
210
+ if (!res.ok) {
211
+ showAlert(data?.error || `μ‚­μ œ μ‹€νŒ¨ (${res.status})`, 'error');
212
+ return;
213
+ }
214
+ showAlert('μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
215
+ await reload();
216
+ }
217
+
218
+ reload();
219
+ </script>
220
+ </body>
221
+ </html>
222
+
223
+
templates/admin_webtoon_stage_planner.html CHANGED
@@ -205,6 +205,8 @@
205
  const epStageSummary = document.getElementById('epStageSummary');
206
  const stagePlanWrap = document.getElementById('stagePlanWrap');
207
 
 
 
208
  function showAlert(message, type) {
209
  alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
210
  setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
@@ -222,6 +224,26 @@
222
  return div.innerHTML;
223
  }
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  let cachedEpStats = null;
226
 
227
  async function loadEpStageStats() {
@@ -261,7 +283,7 @@
261
  }
262
  epStageTbody.innerHTML = stages.map(s => `
263
  <tr>
264
- <td><strong>${escapeHtml(s.stage_key || '')}</strong></td>
265
  <td>${s.count ?? 0}</td>
266
  <td>${s.recommended_days ?? '-'}</td>
267
  <td>${s.median_days ?? '-'}</td>
@@ -296,7 +318,7 @@
296
  const header = `${e.episode_num}ν™” Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}일)`;
297
  const rows = (e.stages || []).map(s => `
298
  <tr>
299
- <td style="width:90px;"><strong>${escapeHtml(s.stage_key || '')}</strong></td>
300
  <td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
301
  <td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
302
  <td style="width:90px;">${s.duration_days || 0}</td>
@@ -308,7 +330,7 @@
308
  <div style="margin-top:10px;">
309
  <table>
310
  <thead>
311
- <tr><th style="width:90px;">단계킀</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
312
  </thead>
313
  <tbody>${rows}</tbody>
314
  </table>
@@ -384,12 +406,12 @@
384
  <div style="margin-top:10px;">
385
  <table>
386
  <thead>
387
- <tr><th style="width:90px;">단계킀</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
388
  </thead>
389
  <tbody>
390
  ${g.map(s => `
391
  <tr>
392
- <td><strong>${escapeHtml(s.stage_key || '')}</strong></td>
393
  <td class="mono">${escapeHtml(s.start_date || '')}</td>
394
  <td class="mono">${escapeHtml(s.end_date || '')}</td>
395
  <td>${s.duration_days || 0}</td>
@@ -430,9 +452,14 @@
430
  showAlert('일제 μ§„ν–‰ 역산이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
431
  }
432
 
433
- episodeSelect.addEventListener('change', () => renderEpStageTable(parseInt(episodeSelect.value, 10) || 1));
434
- basisEl.addEventListener('change', () => loadAll());
435
- loadAll();
 
 
 
 
 
436
  </script>
437
  </body>
438
  </html>
 
205
  const epStageSummary = document.getElementById('epStageSummary');
206
  const stagePlanWrap = document.getElementById('stagePlanWrap');
207
 
208
+ let stageKeyMap = {};
209
+
210
  function showAlert(message, type) {
211
  alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
212
  setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
 
224
  return div.innerHTML;
225
  }
226
 
227
+ async function loadStageKeyMap() {
228
+ const { res, data } = await fetchJson('/api/webtoons/stage-keys');
229
+ if (!res.ok) {
230
+ stageKeyMap = {};
231
+ return;
232
+ }
233
+ const items = data?.mappings || [];
234
+ stageKeyMap = {};
235
+ for (const it of items) {
236
+ const k = String(it?.stage_key || '').trim();
237
+ const v = String(it?.label || '').trim();
238
+ if (k && v) stageKeyMap[k] = v;
239
+ }
240
+ }
241
+
242
+ function displayStageLabel(stageKey) {
243
+ const k = String(stageKey || '').trim();
244
+ return stageKeyMap[k] || k;
245
+ }
246
+
247
  let cachedEpStats = null;
248
 
249
  async function loadEpStageStats() {
 
283
  }
284
  epStageTbody.innerHTML = stages.map(s => `
285
  <tr>
286
+ <td><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
287
  <td>${s.count ?? 0}</td>
288
  <td>${s.recommended_days ?? '-'}</td>
289
  <td>${s.median_days ?? '-'}</td>
 
318
  const header = `${e.episode_num}ν™” Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}일)`;
319
  const rows = (e.stages || []).map(s => `
320
  <tr>
321
+ <td style="width:90px;"><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
322
  <td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
323
  <td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
324
  <td style="width:90px;">${s.duration_days || 0}</td>
 
330
  <div style="margin-top:10px;">
331
  <table>
332
  <thead>
333
+ <tr><th style="width:90px;">단계</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
334
  </thead>
335
  <tbody>${rows}</tbody>
336
  </table>
 
406
  <div style="margin-top:10px;">
407
  <table>
408
  <thead>
409
+ <tr><th style="width:90px;">단계</th><th style="width:130px;">μ‹œμž‘</th><th style="width:130px;">μ’…λ£Œ</th><th style="width:90px;">일수</th></tr>
410
  </thead>
411
  <tbody>
412
  ${g.map(s => `
413
  <tr>
414
+ <td><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
415
  <td class="mono">${escapeHtml(s.start_date || '')}</td>
416
  <td class="mono">${escapeHtml(s.end_date || '')}</td>
417
  <td>${s.duration_days || 0}</td>
 
452
  showAlert('일제 μ§„ν–‰ 역산이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
453
  }
454
 
455
+ async function init() {
456
+ await loadStageKeyMap();
457
+ episodeSelect.addEventListener('change', () => renderEpStageTable(parseInt(episodeSelect.value, 10) || 1));
458
+ basisEl.addEventListener('change', () => loadAll());
459
+ await loadAll();
460
+ }
461
+
462
+ init();
463
  </script>
464
  </body>
465
  </html>