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 }