음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue