feat: add kafka admin integration

This commit is contained in:
richjohnrambo
2026-08-28 00:05:54 +08:00
parent b2e5097708
commit 5cc6eebc1a
48 changed files with 8065 additions and 55 deletions
+243 -14
View File
@@ -68,7 +68,7 @@ const (
imQRCodeMaxExpiresInSeconds = 7 * 86400
imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
monthlyReportPreviewMaxRunes = 120
reportPreviewMaxRunes = 120
)
type imHTTPResponse struct {
@@ -170,6 +170,10 @@ func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http
next.ServeHTTP(wrt, req)
return
}
if req.Method == http.MethodPost && strings.TrimSpace(req.URL.Path) == "/v1/im/tenants/resolve" {
next.ServeHTTP(wrt, req)
return
}
if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") {
next.ServeHTTP(wrt, req)
return
@@ -396,6 +400,16 @@ type imFavoritesForwardRequest struct {
Comment string `json:"comment,omitempty"`
}
type imMessagesForwardRequest struct {
Mode string `json:"mode"`
Sources []struct {
Topic string `json:"topic"`
Seq int64 `json:"seq,string"`
} `json:"sources"`
TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"`
}
type imFriendRequestCreateRequest struct {
TargetUserID string `json:"target_user_id"`
Message string `json:"message,omitempty"`
@@ -1886,6 +1900,8 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
return
}
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
@@ -1979,6 +1995,8 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID
return
}
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
@@ -2006,6 +2024,8 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID
return
}
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
@@ -2039,6 +2059,8 @@ func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meeting
return
}
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID,
meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)})
writeIMSuccess(wrt, req, map[string]any{
"meeting_id": meetingID,
"reminded": meetingParticipantPayloads(participants),
@@ -2072,6 +2094,8 @@ func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetin
return
}
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
@@ -2126,6 +2150,8 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.created", report.ReportID,
[]types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -2172,6 +2198,8 @@ func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID,
[]types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
}
@@ -2197,6 +2225,8 @@ func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, rep
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.submitted", report.ReportID,
[]types.Uid{principal.Uid}, "日报已提交", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
}
@@ -2288,6 +2318,8 @@ func serveIMDailyReportReminders(wrt http.ResponseWriter, req *http.Request) {
for _, uid := range reminded {
items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"})
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.reminded", date.Format("2006-01-02"),
reminded, "日报提醒", date.Format("2006-01-02"), map[string]any{"date": date.Format("2006-01-02"), "reminded_count": len(reminded)})
writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items})
}
@@ -2332,6 +2364,8 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.created", report.ReportID,
[]types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -2377,6 +2411,8 @@ func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportI
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID,
[]types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
@@ -2403,6 +2439,8 @@ func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, r
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID,
[]types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
@@ -2426,6 +2464,8 @@ func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, repo
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.reopened", report.ReportID,
[]types.Uid{principal.Uid}, "月报已重开", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
@@ -2541,6 +2581,8 @@ func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "mailbox", "mailbox.submitted", submission.SubmissionID,
[]types.Uid{principal.Uid}, "总经理信箱", submission.Subject, map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous})
writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission))
}
@@ -3201,7 +3243,7 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
},
Content: favoriteForwardContent(item, comment),
}
saved, err := store.Messages.Save(principal.TenantID, msg, nil, true)
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
@@ -3212,6 +3254,107 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imMessagesForwardRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
mode := strings.TrimSpace(body.Mode)
if mode == "" {
mode = "separate"
}
if mode != "separate" {
writeIMError(wrt, req, http.StatusNotImplemented, 30004, "NOT_IMPLEMENTED", "forward bundle is not implemented", map[string]any{"field": "mode"})
return
}
if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
sourceReadable := make(map[string]error)
targetWritable := make(map[string]error)
items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources))
failed := make([]map[string]any, 0)
for _, rawTarget := range body.TargetTopics {
target := strings.TrimSpace(rawTarget)
if target == "" {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, imReasonMalformedRequest))
}
continue
}
targetErr, ok := targetWritable[target]
if !ok {
targetErr = requireTopicWriter(principal, target)
targetWritable[target] = targetErr
}
if targetErr != nil {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, relationErrorReason(targetErr)))
}
continue
}
for _, source := range body.Sources {
sourceTopic := strings.TrimSpace(source.Topic)
if sourceTopic == "" || source.Seq <= 0 {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, imReasonMalformedRequest))
continue
}
readErr, ok := sourceReadable[sourceTopic]
if !ok {
readErr = requireTopicReader(principal, sourceTopic)
sourceReadable[sourceTopic] = readErr
}
if readErr != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(readErr)))
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, source.Seq)
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err)))
continue
}
if msg == nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "NOT_FOUND"))
continue
}
if messageAvailability(*msg) != "available" {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "MESSAGE_UNAVAILABLE"))
continue
}
clientMid := "fwd_" + store.Store.GetUidString()
forwarded := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: forwardedMessageHead(msg, clientMid),
Content: forwardedMessageContent(msg, body.Comment),
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err)))
continue
}
items = append(items, map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": source.Seq},
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "sent",
})
}
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed})
}
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/draft"):
@@ -4500,6 +4643,9 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
writeIMRelationError(wrt, req, err)
return
}
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileCreate, file, principal.Uid); err != nil {
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
}
writeIMSuccess(wrt, req, groupFilePayload(file))
case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete:
groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/"))
@@ -4513,6 +4659,9 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
writeIMRelationError(wrt, req, err)
return
}
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileDelete, file, principal.Uid); err != nil {
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
}
writeIMSuccess(wrt, req, groupFilePayload(file))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -7134,6 +7283,33 @@ func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[s
return items
}
func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid {
if meeting == nil {
return nil
}
return meetingParticipantUserIDs(meeting.Participants)
}
func meetingParticipantUserIDs(participants []types.MeetingParticipant) []types.Uid {
items := make([]types.Uid, 0, len(participants))
for _, participant := range participants {
if !participant.UserID.IsZero() {
items = append(items, participant.UserID)
}
}
return items
}
func publishIMWorkbenchAndAudit(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string,
targets []types.Uid, title, summary string, data map[string]any) {
if err := enqueueKafkaWorkbenchEvent(ctx, tenantID, actor, module, action, resourceID, targets, title, summary, data); err != nil {
logs.Warn.Printf("im http: failed to enqueue workbench event action=%s resource=%s: %v", action, resourceID, err)
}
if err := enqueueKafkaAuditEvent(ctx, tenantID, "user", actor, action, module, resourceID, "succeeded", "", data); err != nil {
logs.Warn.Printf("im http: failed to enqueue audit event action=%s resource=%s: %v", action, resourceID, err)
}
}
func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
isOrganizer := meeting.OrganizerID == viewer
isScheduled := meeting.State == types.MeetingStateScheduled
@@ -7168,7 +7344,7 @@ func dailyReportListPayload(report types.DailyReport) map[string]any {
"report_id": report.ReportID,
"author_id": report.AuthorID.UserId(),
"report_date": report.ReportDate.Format("2006-01-02"),
"content_short": monthlyReportPreview(report.Content),
"content_short": reportPreview(report.Content),
"status": report.Status,
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
"read_state": report.ReadState,
@@ -7211,8 +7387,8 @@ func monthlyReportListPayload(report types.MonthlyReport) map[string]any {
"report_id": report.ReportID,
"author_id": report.AuthorID.UserId(),
"month": report.Month,
"work_summary_short": monthlyReportPreview(report.WorkSummary),
"next_plan_short": monthlyReportPreview(report.NextPlan),
"work_summary_short": reportPreview(report.WorkSummary),
"next_plan_short": reportPreview(report.NextPlan),
"status": report.Status,
"read_state": report.ReadState,
"version": report.Version,
@@ -7225,13 +7401,13 @@ func monthlyReportListPayload(report types.MonthlyReport) map[string]any {
return data
}
func monthlyReportPreview(text string) string {
func reportPreview(text string) string {
text = strings.Join(strings.Fields(text), " ")
runes := []rune(text)
if len(runes) <= monthlyReportPreviewMaxRunes {
if len(runes) <= reportPreviewMaxRunes {
return text
}
return string(runes[:monthlyReportPreviewMaxRunes]) + "..."
return string(runes[:reportPreviewMaxRunes]) + "..."
}
func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any {
@@ -7826,6 +8002,20 @@ func requireTopicWriter(principal *imPrincipal, topic string) error {
return nil
}
func requireTopicReader(principal *imPrincipal, topic string) error {
if principal == nil || topic == "" {
return types.ErrMalformed
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
return types.ErrPermissionDenied
}
return nil
}
func copyStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
@@ -7956,6 +8146,49 @@ func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string
return payload
}
func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any {
return map[string]any{
"source": map[string]any{"topic": strings.TrimSpace(topic), "seq": seq},
"target_topic": strings.TrimSpace(target),
"status": "failed",
"reason": reason,
}
}
func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
head := types.KVMap{}
if msg != nil {
for key, value := range msg.Head {
head[key] = value
}
msgType := imMessageType(msg)
if strings.TrimSpace(fmt.Sprint(head["type"])) == "" || fmt.Sprint(head["type"]) == "<nil>" {
head["type"] = msgType
}
if strings.TrimSpace(fmt.Sprint(head["x-im-type"])) == "" || fmt.Sprint(head["x-im-type"]) == "<nil>" {
head["x-im-type"] = msgType
}
head["x-im-forward-source"] = map[string]any{"topic": msg.Topic, "seq": msg.SeqId, "from": msg.From}
}
head["x-im-forwarded"] = true
head["x-im-client-mid"] = clientMid
return head
}
func forwardedMessageContent(msg *types.Message, comment string) any {
if strings.TrimSpace(comment) == "" {
if msg == nil {
return nil
}
return msg.Content
}
return map[string]any{
"forwarded": true,
"comment": strings.TrimSpace(comment),
"snapshot": favoriteSnapshot(msg),
}
}
func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) {
if rawURL == "" || len(rawURL) > 2048 {
return "block", "high", "invalid_url"
@@ -8802,14 +9035,10 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
return types.ZeroUid
}
prefix := "p2p"
body := strings.TrimPrefix(topic, prefix)
uidLen := len(uid.String())
if len(body) < 2*uidLen {
left, right, err := types.ParseP2P(topic)
if err != nil {
return types.ZeroUid
}
left := types.ParseUid(body[:uidLen])
right := types.ParseUid(body[uidLen:])
if left == uid {
return right
}