iykyka/core-backend/b_routes.go

245 lines
7.7 KiB
Go

package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type registerDeviceRequest struct {
Token string `json:"token" binding:"required"`
Platform string `json:"platform"`
}
func messageJSON(m Message) gin.H {
return gin.H{
"id": m.ID,
"conversation_id": m.ConversationID,
"sender_id": m.SenderID,
"sender_mode": m.SenderMode,
"text": m.Text,
"retracted": m.Retracted,
"created_at": m.CreatedAt,
}
}
func registerBRoutes(r *gin.Engine, db *gorm.DB) {
// Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics.
r.GET("/admin/dashboard", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminDashboardHTML)
})
r.POST("/users/:id/device-tokens", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !requireSelf(c, db, userID) {
return
}
var req registerDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
platform := req.Platform
if platform == "" {
platform = "android"
}
var existing DeviceToken
err := db.Where("token = ?", req.Token).First(&existing).Error
if err == nil {
existing.UserID = userID
existing.Platform = platform
existing.UpdatedAt = time.Now()
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform})
return
}
row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform}
if err := db.Create(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform})
})
r.GET("/users/:id/device-tokens", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !requireSelf(c, db, userID) {
return
}
var tokens []DeviceToken
db.Where("user_id = ?", userID).Order("id desc").Find(&tokens)
out := make([]gin.H, 0, len(tokens))
for _, t := range tokens {
out = append(out, gin.H{
"id": t.ID,
"platform": t.Platform,
"token_tail": trimToken(t.Token),
"updated_at": t.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"device_tokens": out})
})
// Multi-device awareness: list active sessions for the authenticated user.
r.GET("/users/:id/sessions", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
actor, ok := currentUser(c, db, true)
if !ok {
return
}
if actor.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"})
return
}
var sessions []Session
db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions)
out := make([]gin.H, 0, len(sessions))
current := bearerToken(c)
for _, s := range sessions {
out = append(out, gin.H{
"id": s.ID,
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
"is_current": s.Token == current,
})
}
c.JSON(http.StatusOK, gin.H{"sessions": out})
})
// Revoke another (or current) session — multi-device control.
r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
sessionID, ok := parseUintParam(c, "sessionId")
if !ok {
return
}
actor, ok := currentUser(c, db, true)
if !ok {
return
}
if actor.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"})
return
}
var session Session
if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"})
return
}
db.Delete(&session)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID})
})
// Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery).
r.POST("/admin/push-test", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
UserID uint `json:"user_id" binding:"required"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.Title == "" {
req.Title = "와카뷰 테스트"
}
if req.Body == "" {
req.Body = "push test"
}
sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason})
})
}
func trimToken(token string) string {
if len(token) <= 8 {
return "****"
}
return "…" + token[len(token)-6:]
}
const adminDashboardHTML = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>와카뷰 · admin metrics</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
body { margin: 0; padding: 24px; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
p { color: #40554c; margin: 0 0 20px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.card { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; }
.k { font-size: .75rem; color: #5b7267; text-transform: uppercase; letter-spacing: .04em; }
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>와카뷰 운영 지표</h1>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div>
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
<pre id="raw">loading…</pre>
<script>
async function load() {
const params = new URLSearchParams(location.search);
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
if (!token) {
token = prompt('ADMIN_API_TOKEN') || '';
if (token) localStorage.ADMIN_API_TOKEN = token;
} else if (params.get('token')) {
localStorage.ADMIN_API_TOKEN = token;
}
const r = await fetch('/admin/metrics', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} });
const data = await r.json();
document.getElementById('raw').textContent = JSON.stringify(data, null, 2);
const cards = [
['users', data.users_total],
['human msgs', data.messages_human_total],
['twin msgs', data.messages_twin_total],
['escalations', data.escalations_total],
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
['draft req', data.draft_requests],
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
['draft max ms', (data.draft_latency_max_ms || 0).toFixed(1)],
['escalate err rate', (data.escalate_error_rate || 0).toFixed(3)],
];
document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
}
load();
</script>
</body>
</html>
`