음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -152,6 +152,8 @@ class AdminSupportTicketResponse(BaseModel):
created_at: float
updated_at: float
resolved_at: float | None = None
event_count: int = 0
last_event_at: float | None = None
class AdminTicketSummary(BaseModel):
@ -545,31 +547,67 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
async def _tickets_from_database(
*,
ticket_status: TicketStatus | None,
category: TicketCategory | None = None,
priority: TicketPriority | None = None,
assigned_group: str | None = None,
source_path: str | None = None,
stale_only: bool = False,
search: str = "",
window_days: int,
) -> AdminTicketsResponse:
assigned_group_filter = (assigned_group or "").strip()
source_path_filter = (source_path or "").strip()
search_filter = search.strip().lower()
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
FROM app.support_ticket
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
WHERE ($1::text IS NULL OR status = $1)
AND created_at >= now() - ($2::int * interval '1 day')
AND ($2::text IS NULL OR category = $2)
AND ($3::text IS NULL OR priority = $3)
AND ($4::text = '' OR assigned_group = $4)
AND ($5::text = '' OR source_path = $5)
AND (
NOT $6::bool
OR (
status NOT IN ('resolved', 'closed')
AND updated_at < now() - interval '1 day'
)
)
AND (
$7::text = ''
OR lower(subject) LIKE '%' || $7 || '%'
OR lower(body) LIKE '%' || $7 || '%'
OR lower(source_path) LIKE '%' || $7 || '%'
OR lower(reporter_email) LIKE '%' || $7 || '%'
)
AND created_at >= now() - ($8::int * interval '1 day')
ORDER BY
CASE
WHEN status = 'open' THEN 0
@ -588,6 +626,12 @@ async def _tickets_from_database(
LIMIT 120
""",
ticket_status,
category,
priority,
assigned_group_filter,
source_path_filter,
stale_only,
search_filter,
window_days,
)
tickets = [_ticket_from_row(row) for row in rows]
@ -758,6 +802,13 @@ def _row_ts(value: object) -> float | None:
return None
def _row_value(row, key: str, default=None):
try:
return row[key]
except (IndexError, KeyError, TypeError):
return default
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=_normalize_engine_mode(row["engine_mode"]),
@ -804,6 +855,8 @@ def _ticket_from_row(row) -> AdminSupportTicketResponse:
created_at=_row_ts(row["created_at"]) or 0.0,
updated_at=_row_ts(row["updated_at"]) or 0.0,
resolved_at=_row_ts(row["resolved_at"]),
event_count=int(_row_value(row, "event_count", 0) or 0),
last_event_at=_row_ts(_row_value(row, "last_event_at")),
)
@ -872,6 +925,50 @@ def _unavailable_tickets() -> AdminTicketsResponse:
)
def _ticket_change_detail(old_row, new_row) -> dict[str, object]:
changed_fields: list[str] = []
detail: dict[str, object] = {"changed_fields": changed_fields}
for field in ("status", "priority", "assigned_group"):
before = _row_value(old_row, field, "")
after = _row_value(new_row, field, "")
if before != after:
changed_fields.append(field)
detail[field] = {"from": before, "to": after}
old_note = (_row_value(old_row, "resolution_note", "") or "").strip()
new_note = (_row_value(new_row, "resolution_note", "") or "").strip()
if old_note != new_note:
changed_fields.append("resolution_note")
detail["resolution_note"] = {
"from_present": bool(old_note),
"to_present": bool(new_note),
}
detail["category"] = _row_value(new_row, "category", "")
detail["source_path"] = _row_value(new_row, "source_path", "")
return detail
async def _record_ticket_update_audit(
conn,
*,
principal: Principal,
ticket_id: str,
detail: dict[str, object],
) -> None:
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
principal.user_id,
"support_ticket_update",
"support_ticket",
ticket_id,
detail,
)
async def _current_engine_config() -> AdminEngineConfigResponse:
if _ENGINE_CONFIG is not None:
return _ENGINE_CONFIG
@ -1090,11 +1187,26 @@ async def admin_uptime(
async def list_tickets(
principal: AdminPrincipal,
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
category: TicketCategory | None = None,
priority: TicketPriority | None = None,
assigned_group: Annotated[str | None, Query(max_length=120)] = None,
source_path: Annotated[str | None, Query(max_length=300)] = None,
stale_only: bool = False,
search: Annotated[str, Query(max_length=120)] = "",
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AdminTicketsResponse:
"""Return user-submitted operational tickets without synthetic fallback rows."""
try:
return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days)
return await _tickets_from_database(
ticket_status=ticket_status,
category=category,
priority=priority,
assigned_group=assigned_group,
source_path=source_path,
stale_only=stale_only,
search=search,
window_days=window_days,
)
except Exception:
return _unavailable_tickets()
@ -1108,6 +1220,23 @@ async def patch_ticket(
"""Update ticket triage state for administrators."""
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
old_row = await conn.fetchrow(
"""
SELECT
id,
category,
priority,
status,
source_path,
assigned_group,
resolution_note
FROM app.support_ticket
WHERE id = $1::uuid
""",
ticket_id,
)
if old_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
row = await conn.fetchrow(
"""
UPDATE app.support_ticket SET
@ -1149,13 +1278,56 @@ async def patch_ticket(
body.assigned_group.strip() if body.assigned_group is not None else None,
body.resolution_note.strip() if body.resolution_note is not None else None,
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
detail = _ticket_change_detail(old_row, row)
if detail["changed_fields"]:
await _record_ticket_update_audit(
conn,
principal=principal,
ticket_id=ticket_id,
detail=detail,
)
row = await conn.fetchrow(
"""
SELECT
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
WHERE t.id = $1::uuid
""",
ticket_id,
)
except Exception as exc:
if isinstance(exc, HTTPException):
raise
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ticket persistence unavailable",
) from exc
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
return _ticket_from_row(row)