spark-server/internal/transport/http/handler/admin/im_groups.go
DESKTOP-L8OUL9F\gfl0034 292ac26ea6 fix(popadmin): IM消息展示可读时间并支持时段筛选
后端 GET /im/messages 增加 start/end(最长12个月),前端格式化 ts 并提供日/周/月/年/总/自定义筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 15:05:54 +08:00

251 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package adminhandler
import (
"archive/zip"
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
adminlogic "spark-server/internal/module/admin/logic"
"spark-server/internal/pkg/errs"
)
type Handler struct {
svc *adminlogic.Service
}
func New(svc *adminlogic.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) ready(c *gin.Context) bool {
if h == nil || h.svc == nil {
Fail(c, http.StatusServiceUnavailable, "admin 服务未就绪")
return false
}
return true
}
func (h *Handler) ImStats(c *gin.Context) {
if !h.ready(c) {
return
}
out, err := h.svc.ImStats(c.Request.Context())
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, out)
}
func (h *Handler) ImMessages(c *gin.Context) {
if !h.ready(c) {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
list, err := h.svc.ListMessages(c.Request.Context(), c.Query("q"), limit, c.Query("start"), c.Query("end"))
if err != nil {
if errors.Is(err, errs.ErrInvalidArgument) {
msg := "start/end 无效"
if ae, ok := errs.AsAppError(err); ok && ae.CustomMsg() {
msg = ae.Msg
}
Fail(c, http.StatusBadRequest, msg)
return
}
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, gin.H{"messages": list, "count": len(list)})
}
func (h *Handler) ImConversations(c *gin.Context) {
if !h.ready(c) {
return
}
userID, _ := strconv.ParseInt(c.DefaultQuery("userId", "0"), 10, 64)
convs, groups, stats, err := h.svc.ListConversations(c.Request.Context(), userID)
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
if userID <= 0 {
OK(c, gin.H{"stats": stats, "note": "传 userId 可查看该用户会话;否则仅返回统计"})
return
}
OK(c, gin.H{"conversations": convs, "groups": groups})
}
func (h *Handler) ListGroups(c *gin.Context) {
if !h.ready(c) {
return
}
list, err := h.svc.ListGroups(c.Request.Context(), c.Query("q"))
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, list)
}
func (h *Handler) GroupDetail(c *gin.Context) {
if !h.ready(c) {
return
}
g, err := h.svc.GroupDetail(c.Request.Context(), c.Param("groupId"))
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
Fail(c, http.StatusNotFound, "群不存在")
return
}
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, g)
}
func (h *Handler) UpsertGroup(c *gin.Context) {
Fail(c, http.StatusBadRequest, "V3 群由客户端创建;请使用 DELETE 解散已有群")
}
func (h *Handler) DeleteGroup(c *gin.Context) {
if !h.ready(c) {
return
}
if err := h.svc.DissolveGroup(c.Request.Context(), c.Param("groupId")); err != nil {
if errors.Is(err, errs.ErrNotFound) {
Fail(c, http.StatusNotFound, "群不存在")
return
}
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OKMsg(c, "已解散", nil)
}
func (h *Handler) ExportGroupZip(c *gin.Context) {
if !h.ready(c) {
return
}
g, err := h.svc.GroupDetail(c.Request.Context(), c.Param("groupId"))
if err != nil {
c.Status(http.StatusNotFound)
return
}
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
csvBuf := &bytes.Buffer{}
w := csv.NewWriter(csvBuf)
_ = w.Write([]string{"userId", "email", "nickname", "role"})
for i := range g.Members {
m := &g.Members[i]
_ = w.Write([]string{
strconv.FormatInt(m.UserID, 10), m.Email, m.Nickname, m.Role,
})
}
w.Flush()
f1, _ := zw.Create("members.csv")
_, _ = f1.Write(csvBuf.Bytes())
msgJSON, _ := json.Marshal(g.Messages)
f2, _ := zw.Create("messages.json")
_, _ = f2.Write(msgJSON)
_ = zw.Close()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="group-%s.zip"`, g.GroupID))
c.Data(http.StatusOK, "application/zip", buf.Bytes())
}
func (h *Handler) ListComplaints(c *gin.Context) {
if !h.ready(c) {
return
}
list, err := h.svc.ListComplaints(c.Request.Context())
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, list)
}
func (h *Handler) AddComplaint(c *gin.Context) {
if !h.ready(c) {
return
}
var body map[string]any
_ = c.ShouldBindJSON(&body)
out, err := h.svc.AddComplaint(c.Request.Context(), str(body, "email"), str(body, "target"), str(body, "reason"), str(body, "detail"))
if err != nil {
if errors.Is(err, errs.ErrInvalidArgument) {
Fail(c, http.StatusBadRequest, "email/target 无效")
return
}
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, out)
}
func (h *Handler) SameIPRank(c *gin.Context) {
if !h.ready(c) {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := h.svc.SameIPRank(c.Request.Context(), limit)
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, gin.H{"items": items, "appCode": "spark"})
}
func (h *Handler) Ranking(c *gin.Context) {
OK(c, gin.H{
"type": c.Param("type"), "date": c.Query("date"),
"items": []any{},
"note": "日排行依赖客户端上报;V3 暂未聚合",
})
}
func (h *Handler) OpsCatalog(c *gin.Context) {
OK(c, gin.H{
"module": "spark-server-admin",
"im": []string{"/im/stats", "/im/messages", "/im/conversations"},
"groups": []string{"/groups", "/groups/{id}", "/groups/{id}/export-zip"},
"users": []string{"/users/by-nickname", "/users/by-device", "/users/by-ip"},
})
}
func (h *Handler) APIDoc(c *gin.Context) {
OK(c, gin.H{
"title": "Spark IM V3 Admin API",
"paths": []gin.H{
{"method": "GET", "path": "/api/admin/spark/im/stats", "desc": "IM 统计"},
{"method": "GET", "path": "/api/admin/spark/im/messages", "desc": "消息检索"},
{"method": "GET", "path": "/api/admin/spark/im/conversations", "desc": "用户会话"},
{"method": "GET", "path": "/api/admin/spark/groups", "desc": "群列表"},
{"method": "GET", "path": "/api/admin/spark/groups/{id}", "desc": "群详情"},
{"method": "DELETE", "path": "/api/admin/spark/groups/{id}", "desc": "解散群"},
{"method": "GET", "path": "/api/admin/users", "desc": "用户列表(V3)"},
},
"aliases": []string{"/api/admin/spark ≡ /api/admin/pop"},
"auth": "Bearer admin JWT (type=admin),与 Java popadmin 登录同密钥",
})
}
func str(body map[string]any, key string) string {
if body == nil {
return ""
}
v, ok := body[key]
if !ok || v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(v))
}