feat: 实现管理后台 A3 注册群消息概览与 metrics 门禁
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/group"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
)
|
||||
|
||||
func (h *Handler) requireGroups(w http.ResponseWriter) bool {
|
||||
if h.groups == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "busy", "群服务未注入")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, offset, ok := parsePage(w, q.Get("limit"), q.Get("cursor"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(q.Get("query"))
|
||||
|
||||
where := "1=1"
|
||||
args := make([]any, 0, 4)
|
||||
if query != "" {
|
||||
where += " AND LOWER(g.name) LIKE ?"
|
||||
args = append(args, "%"+strings.ToLower(query)+"%")
|
||||
}
|
||||
|
||||
var total int
|
||||
countSQL := `SELECT COUNT(*) FROM groups g WHERE ` + where
|
||||
if err := h.db.Read.QueryRowContext(r.Context(), countSQL, args...).Scan(&total); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
listArgs := append(append([]any{}, args...), limit, offset)
|
||||
rows, err := h.db.Read.QueryContext(r.Context(), `
|
||||
SELECT g.id, g.name, g.owner_id, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS cnt
|
||||
FROM groups g
|
||||
WHERE `+where+`
|
||||
ORDER BY g.id ASC
|
||||
LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, name, owner string
|
||||
var created int64
|
||||
var cnt int
|
||||
if scanErr := rows.Scan(&id, &name, &owner, &created, &cnt); scanErr != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"owner_id": owner,
|
||||
"member_count": cnt,
|
||||
"created_at_ms": created,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
next := ""
|
||||
if offset+len(items) < total {
|
||||
next = strconv.Itoa(offset + len(items))
|
||||
}
|
||||
httpx.WriteOK(w, map[string]any{"items": items, "next_cursor": next, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
}
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ID) != "" {
|
||||
// AdminCreate 不接受自定义 id;与契约「留空则生成」一致时忽略非空会误导,故拒绝。
|
||||
h.audit(actorString(p), "group_create", req.ID, "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "后台创建群请留空 id,由服务器生成")
|
||||
return
|
||||
}
|
||||
if !protocol.ValidName(req.Name) || req.Name == "" {
|
||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不合法")
|
||||
return
|
||||
}
|
||||
if req.OwnerID == "" {
|
||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "缺少 owner_id")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.groups.AdminCreate(r.Context(), req.Name, req.OwnerID, req.MemberIDs)
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_create", req.OwnerID, ip, err)
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "group_create", res.ID, "ok", ip)
|
||||
failed := res.Failed
|
||||
if failed == nil {
|
||||
failed = []group.MemberFail{}
|
||||
}
|
||||
httpx.WriteOK(w, map[string]any{
|
||||
"id": res.ID,
|
||||
"name": res.Name,
|
||||
"owner_id": res.OwnerID,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
q := r.URL.Query()
|
||||
limit, offset, ok := parsePage(w, q.Get("limit"), q.Get("cursor"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var name, owner string
|
||||
var created int64
|
||||
err := h.db.Read.QueryRowContext(r.Context(),
|
||||
`SELECT name, owner_id, created_at FROM groups WHERE id = ?`, id,
|
||||
).Scan(&name, &owner, &created)
|
||||
if isNoRows(err) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.Read.QueryContext(r.Context(), `
|
||||
SELECT gm.endpoint_id, COALESCE(e.name, ''), gm.joined_at,
|
||||
e.online_since, e.offline_since
|
||||
FROM group_members gm
|
||||
LEFT JOIN endpoints e ON e.id = gm.endpoint_id
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY gm.endpoint_id ASC
|
||||
LIMIT ? OFFSET ?`, id, limit, offset)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
members := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var eid, ename string
|
||||
var joined int64
|
||||
var onlineSince, offlineSince sql.NullInt64
|
||||
if scanErr := rows.Scan(&eid, &ename, &joined, &onlineSince, &offlineSince); scanErr != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
online, _, _ := presenceFields(onlineSince, offlineSince)
|
||||
members = append(members, map[string]any{
|
||||
"id": eid,
|
||||
"name": ename,
|
||||
"online": online,
|
||||
"joined_at_ms": joined,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
var memberTotal int
|
||||
_ = h.db.Read.QueryRowContext(r.Context(),
|
||||
`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, id).Scan(&memberTotal)
|
||||
next := ""
|
||||
if offset+len(members) < memberTotal {
|
||||
next = strconv.Itoa(offset + len(members))
|
||||
}
|
||||
httpx.WriteOK(w, map[string]any{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"owner_id": owner,
|
||||
"created_at_ms": created,
|
||||
"members": members,
|
||||
"next_cursor": next,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupRename(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
id := r.PathValue("id")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
h.audit(actorString(p), "group_rename", id, "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||
return
|
||||
}
|
||||
owner, err := h.groupOwner(r.Context(), id)
|
||||
if isNoRows(err) {
|
||||
h.audit(actorString(p), "group_rename", id, "not_found", ip)
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "group_rename", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
err = h.groups.Rename(r.Context(), owner, &protocol.GroupRename{
|
||||
V: protocol.Version, Type: protocol.TypeGroupRename, RID: "admin",
|
||||
GroupID: id, Name: req.Name,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_rename", id, ip, err)
|
||||
return
|
||||
}
|
||||
summary, err := h.groupSummary(r.Context(), id)
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "group_rename", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "group_rename", id, "ok", ip)
|
||||
httpx.WriteOK(w, summary)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupDissolve(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
id := r.PathValue("id")
|
||||
|
||||
owner, err := h.groupOwner(r.Context(), id)
|
||||
if isNoRows(err) {
|
||||
h.audit(actorString(p), "group_dissolve", id, "not_found", ip)
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "group_dissolve", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
err = h.groups.Dissolve(r.Context(), owner, &protocol.GroupDissolve{
|
||||
V: protocol.Version, Type: protocol.TypeGroupDissolve, RID: "admin", GroupID: id,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_dissolve", id, ip, err)
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "group_dissolve", id, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupAddMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
id := r.PathValue("id")
|
||||
|
||||
var req struct {
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
}
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
h.audit(actorString(p), "group_add_members", id, "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||
return
|
||||
}
|
||||
if _, err := h.groupOwner(r.Context(), id); isNoRows(err) {
|
||||
h.audit(actorString(p), "group_add_members", id, "not_found", ip)
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
} else if err != nil {
|
||||
h.audit(actorString(p), "group_add_members", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.groups.AdminAddMembers(r.Context(), id, req.MemberIDs)
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_add_members", id, ip, err)
|
||||
return
|
||||
}
|
||||
failed := res.Failed
|
||||
if failed == nil {
|
||||
failed = []group.MemberFail{}
|
||||
}
|
||||
h.audit(actorString(p), "group_add_members", id, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{"failed": failed})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
id := r.PathValue("id")
|
||||
endpointID := r.PathValue("endpointId")
|
||||
|
||||
owner, err := h.groupOwner(r.Context(), id)
|
||||
if isNoRows(err) {
|
||||
h.audit(actorString(p), "group_remove_member", id, "not_found", ip)
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "group_remove_member", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
err = h.groups.Remove(r.Context(), owner, &protocol.GroupRemove{
|
||||
V: protocol.Version, Type: protocol.TypeGroupRemove, RID: "admin",
|
||||
GroupID: id, EndpointID: endpointID,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_remove_member", id, ip, err)
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "group_remove_member", id+"/"+endpointID, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGroupTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireGroups(w) {
|
||||
return
|
||||
}
|
||||
p, _ := principalFrom(r.Context())
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
id := r.PathValue("id")
|
||||
|
||||
var req struct {
|
||||
EndpointID string `json:"endpoint_id"`
|
||||
}
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
h.audit(actorString(p), "group_transfer", id, "bad_request", ip)
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||
return
|
||||
}
|
||||
owner, err := h.groupOwner(r.Context(), id)
|
||||
if isNoRows(err) {
|
||||
h.audit(actorString(p), "group_transfer", id, "not_found", ip)
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.audit(actorString(p), "group_transfer", id, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
err = h.groups.Transfer(r.Context(), owner, &protocol.GroupTransfer{
|
||||
V: protocol.Version, Type: protocol.TypeGroupTransfer, RID: "admin",
|
||||
GroupID: id, EndpointID: req.EndpointID,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeGroupErr(w, p, "group_transfer", id, ip, err)
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), "group_transfer", id, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{"owner_id": req.EndpointID})
|
||||
}
|
||||
|
||||
func (h *Handler) groupOwner(ctx context.Context, groupID string) (string, error) {
|
||||
var owner string
|
||||
err := h.db.Read.QueryRowContext(ctx, `SELECT owner_id FROM groups WHERE id = ?`, groupID).Scan(&owner)
|
||||
return owner, err
|
||||
}
|
||||
|
||||
func (h *Handler) groupSummary(ctx context.Context, groupID string) (map[string]any, error) {
|
||||
var name, owner string
|
||||
var created int64
|
||||
var cnt int
|
||||
err := h.db.Read.QueryRowContext(ctx, `
|
||||
SELECT g.name, g.owner_id, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id)
|
||||
FROM groups g WHERE g.id = ?`, groupID).Scan(&name, &owner, &created, &cnt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": groupID,
|
||||
"name": name,
|
||||
"owner_id": owner,
|
||||
"member_count": cnt,
|
||||
"created_at_ms": created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) writeGroupErr(w http.ResponseWriter, p principal, action, object, ip string, err error) {
|
||||
var pe *protocol.Error
|
||||
if errors.As(err, &pe) {
|
||||
status := http.StatusBadRequest
|
||||
switch pe.Code {
|
||||
case protocol.CodeNotFound, protocol.CodeInvalidTarget:
|
||||
status = http.StatusNotFound
|
||||
case protocol.CodeForbidden, protocol.CodeNotMember, protocol.CodeOwnerCannotLeave:
|
||||
status = http.StatusForbidden
|
||||
case protocol.CodeIDTaken:
|
||||
status = http.StatusConflict
|
||||
case protocol.CodeBusy:
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
h.audit(actorString(p), action, object, pe.Code, ip)
|
||||
httpx.WriteError(w, status, pe.Code, pe.Message)
|
||||
return
|
||||
}
|
||||
h.audit(actorString(p), action, object, "error", ip)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
}
|
||||
|
||||
func parsePage(w http.ResponseWriter, limitStr, cursorStr string) (limit, offset int, ok bool) {
|
||||
limit = defaultListLimit
|
||||
if limitStr != "" {
|
||||
n, err := strconv.Atoi(limitStr)
|
||||
if err != nil || n < 1 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "limit 无效")
|
||||
return 0, 0, false
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
if limit > maxListLimit {
|
||||
limit = maxListLimit
|
||||
}
|
||||
if cursorStr != "" {
|
||||
n, err := strconv.Atoi(cursorStr)
|
||||
if err != nil || n < 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "cursor 无效")
|
||||
return 0, 0, false
|
||||
}
|
||||
offset = n
|
||||
}
|
||||
return limit, offset, true
|
||||
}
|
||||
Reference in New Issue
Block a user