Files
im_server/server/internal_admin_query_resources.go
T
Austin 6bbe955e75 feat: 新增共库模式下登录失败日志表及相关功能
- 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离
- 调整登录失败日志逻辑,仅记录触发账号锁定的事件
- 新增管理端接口功能,为登录失败日志补充实时锁定状态
- 更新测试用例适配共库模式下的租户过滤逻辑
2026-09-23 13:18:28 +08:00

730 lines
35 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 main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type internalQueryFilter struct {
Param string
Column string
Op string
Kind string
}
type internalQuerySpec struct {
Select string
From string
DefaultSort string
Filters []internalQueryFilter
DetailKey string
ScopeColumn string
// TenantColumn 指定共库模式下的租户隔离列(如 "u.tenant_id")。
// 共库模式下所有租户共用同一物理库,列表与详情查询必须无条件注入 tenant_id 谓词。
TenantColumn string
}
// internalAdminTenantCondition 返回单条详情查询的租户过滤片段与追加参数。
func internalAdminTenantCondition(spec *internalQuerySpec, tenantID types.TenantID) (string, []any) {
if spec == nil || spec.TenantColumn == "" {
return "", nil
}
return " AND " + spec.TenantColumn + "=?", []any{int64(tenantID)}
}
var internalGroupResourceSpecs = map[string]*internalQuerySpec{
"join-requests": {
Select: "SELECT id,request_id,topic,applicant_user_id,inviter_user_id,source,message,status,reject_reason,handled_by,handled_at,expires_at,version,created_at,updated_at",
From: "FROM im_group_join_requests", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"applicant_user_id", "applicant_user_id", "=", "uid"}},
},
"invitations": {
Select: "SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,expires_at,handled_at,created_at,updated_at",
From: "FROM im_group_invitations", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"target_user_id", "target_user_id", "=", "uid"}, {"inviter_user_id", "inviter_user_id", "=", "uid"}},
},
"announcements": {
Select: "SELECT id,announcement_id,topic,author_user_id,content,status,version,published_at,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_announcements", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}},
},
"mutes": {
Select: "SELECT id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at",
From: "FROM im_group_mutes", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"user_id", "user_id", "=", "uid"}},
},
"admin-logs": {
Select: "SELECT id,log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key,created_at",
From: "FROM im_group_admin_logs", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"action", "action", "=", "string"}, {"operator_id", "operator_id", "=", "uid"}},
},
"files": {
Select: "SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_files", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"file_type", "file_type", "=", "string"}, {"file_name", "file_name", "LIKE", "string"}, {"uploader_id", "uploader_id", "=", "uid"}},
},
}
var internalTenantResourceSpecs = map[string]*internalQuerySpec{
"private-messages": privateMessagesInternalQuerySpec(),
"group-messages": groupMessagesInternalQuerySpec(),
"user-login-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
},
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,account_hash,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": {
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"actor_user_id", "actor_uid", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"realname-verifications": {
Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at",
From: "FROM im_realname_verifications", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"status", "status", "=", "string"}, {"real_name", "masked_name", "LIKE", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"abuse-reports": {
Select: "SELECT id,report_id,reporter_id AS reporter_user_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at",
From: "FROM im_abuse_reports", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"reporter_user_id", "reporter_id", "=", "uid"}, {"target_type", "target_type", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"feedback-submissions": {
Select: "SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids AS file_ids,client_context,include_diagnostics,status,created_at,updated_at",
From: "FROM im_feedback_submissions", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"category", "category", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"feedback-config": {
Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at",
From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
}, "abuse-reasons": {
Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at",
From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}},
},
"app-releases": {
Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at",
From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
}}
var internalOutboxResourceSpecs = map[string]*internalQuerySpec{}
func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, scope string) {
spec, ok := internalAdminQuerySpec(resource)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
if resource == "group-messages" {
var err error
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
}
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope, tenantID)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var result *types.InternalAdminQueryResult
keyword := strings.TrimSpace(req.URL.Query().Get("keyword"))
if (resource == "private-messages" || resource == "group-messages") && keyword != "" {
result, err = queryEncryptedAdminMessages(req, tenantID, spec, scope, keyword, page, perPage)
} else {
result, err = store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage)
}
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
if resource == "private-messages" || resource == "group-messages" {
if err = decryptInternalAdminMessageRows(tenantID, result.Items); err != nil {
writeInternalQueryError(wrt, req, err)
return
}
}
if resource == "login-failure-logs" {
attachLoginLockoutState(tenantID, result.Items)
}
writeIMSuccess(wrt, req, map[string]any{
"items": normalizeInternalAdminRowsForAPI(result.Items),
"total": result.Total,
"page": result.Page,
"per_page": result.PerPage,
})
}
func queryEncryptedAdminMessages(req *http.Request, tenantID types.TenantID, spec *internalQuerySpec, scope, keyword string, page, perPage int) (*types.InternalAdminQueryResult, error) {
requestCopy := req.Clone(req.Context())
urlCopy := *req.URL
queryValues := req.URL.Query()
queryValues.Del("keyword")
urlCopy.RawQuery = queryValues.Encode()
requestCopy.URL = &urlCopy
query, countQuery, args, _, _, err := buildInternalAdminListQuery(requestCopy, spec, scope, tenantID)
if err != nil {
return nil, err
}
const batchSize = 200
start := (page - 1) * perPage
end := start + perPage
items := make([]map[string]any, 0, perPage)
matched := 0
for batchPage := 1; ; batchPage++ {
batch, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, batchPage, batchSize)
if queryErr != nil {
return nil, queryErr
}
if err = decryptInternalAdminMessageRows(tenantID, batch.Items); err != nil {
return nil, err
}
for _, row := range batch.Items {
if !strings.Contains(strings.ToLower(fmt.Sprint(row["content_excerpt"])), strings.ToLower(keyword)) {
continue
}
if matched >= start && matched < end {
items = append(items, row)
}
matched++
}
if len(batch.Items) < batchSize || int64(batchPage*batchSize) >= batch.Total {
break
}
}
return &types.InternalAdminQueryResult{Items: items, Total: int64(matched), Page: page, PerPage: perPage}, nil
}
func decryptInternalAdminMessageRows(tenantID types.TenantID, rows []map[string]any) error {
for _, row := range rows {
seq, ok := numericID(row["seq_id"])
if !ok || seq <= 0 {
continue
}
msg, err := store.Messages.GetOne(tenantID, fmt.Sprint(row["topic"]), seq)
if err != nil {
return err
}
row["content_excerpt"] = internalMessageExcerpt(msg.Content)
}
return nil
}
func internalMessageExcerpt(content any) string {
if value, ok := content.(map[string]any); ok {
for _, key := range []string{"txt", "text", "content", "title"} {
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
if len([]rune(text)) > 500 {
return string([]rune(text)[:500])
}
return text
}
}
}
raw, _ := json.Marshal(content)
text := string(raw)
if len([]rune(text)) > 500 {
text = string([]rune(text)[:500])
}
return text
}
func serveInternalAdminResourceDetail(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, id string) {
spec, ok := internalAdminQuerySpec(resource)
if !ok || spec.DetailKey == "" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
query := spec.Select + " " + spec.From + " WHERE " + spec.DetailKey + "=?"
args := []any{id}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalDashboardSummary(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
summary := map[string]any{}
queries := map[string]string{
"dead_kafka": "SELECT COUNT(*) AS count FROM im_kafka_dead_letters WHERE status='dead'",
"pending_realname": "SELECT COUNT(*) AS count FROM im_realname_verifications WHERE status='pending'",
"received_abuse": "SELECT COUNT(*) AS count FROM im_abuse_reports WHERE status='received'",
"received_feedback": "SELECT COUNT(*) AS count FROM im_feedback_submissions WHERE status='received'",
"active_groups": "SELECT COUNT(*) AS count FROM im_group_settings WHERE group_status='active'",
"suspended_users": "SELECT COUNT(*) AS count FROM im_users WHERE state=10",
}
for key, query := range queries {
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil)
if err != nil {
summary[key] = nil
continue
}
summary[key] = row["count"]
}
writeIMSuccess(wrt, req, summary)
}
func serveInternalAbuseTargetQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, reportID string) {
row, err := store.InternalAdminQueries.QueryOne(tenantID,
"SELECT id,report_id,target_type,target_user_id,target_topic,target_seq,snapshot FROM im_abuse_reports WHERE report_id=?",
[]any{strings.TrimSpace(reportID)})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
data := normalizeInternalAdminRowForAPI(row)
switch fmt.Sprint(row["target_type"]) {
case "user":
if id, ok := numericID(row["target_user_id"]); ok && id > 0 {
if user, getErr := store.InternalAdminQueries.QueryOne(tenantID, usersInternalQuerySpec().Select+" "+usersInternalQuerySpec().From+" WHERE u.id=?", []any{id}); getErr == nil {
data["target"] = normalizeInternalAdminRowForAPI(user)
}
}
case "message":
topic := fmt.Sprint(row["target_topic"])
seq, _ := numericID(row["target_seq"])
if topic != "" && seq > 0 {
if msg, getErr := store.Messages.GetOne(tenantID, topic, seq); getErr == nil {
data["target"] = map[string]any{"topic": msg.Topic, "seq_id": msg.SeqId, "from": msg.From, "head": msg.Head, "content": msg.Content, "created_at": msg.CreatedAt}
}
}
case "group":
topic := fmt.Sprint(row["target_topic"])
if topic != "" {
if group, getErr := store.InternalAdminQueries.QueryOne(tenantID, groupsInternalQuerySpec().Select+" "+groupsInternalQuerySpec().From+" WHERE t.name=?", []any{topic}); getErr == nil {
data["target"] = normalizeInternalAdminRowForAPI(group)
}
}
}
writeIMSuccess(wrt, req, data)
}
func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
switch resource {
case "users":
return usersInternalQuerySpec(), true
case "user_devices":
return &internalQuerySpec{Select: "SELECT id,deviceid AS device_id,platform,lastseen AS last_seen,lang", From: "FROM im_devices", DefaultSort: "ORDER BY lastseen DESC", ScopeColumn: "userid", TenantColumn: "tenant_id", Filters: []internalQueryFilter{{"device_id", "deviceid", "LIKE", "string"}, {"platform", "platform", "=", "string"}}}, true
case "groups":
return groupsInternalQuerySpec(), true
case "file_links":
return &internalQuerySpec{Select: "SELECT id,topic,msgid AS seq_id,userid AS user_id,createdat AS created_at", From: "FROM im_filemsglinks", DefaultSort: "ORDER BY id DESC", ScopeColumn: "fileid", TenantColumn: "tenant_id"}, true
case "files":
return &internalQuerySpec{Select: "SELECT id,id AS file_id,userid AS user_id,mimetype AS mime_type,size AS file_size,location,etag,status,createdat AS created_at,updatedat AS updated_at", From: "FROM im_fileuploads", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id", Filters: []internalQueryFilter{{"file_id", "id", "=", "int"}, {"user_id", "userid", "=", "uid"}, {"status", "status", "=", "int"}, {"mime_type", "mimetype", "LIKE", "string"}, {"created_at_start", "createdat", ">=", "time"}, {"created_at_end", "createdat", "<=", "time"}}}, true
}
if strings.HasPrefix(resource, "group_") {
spec := internalGroupResourceSpecs[strings.TrimPrefix(resource, "group_")]
return spec, spec != nil
}
if strings.HasPrefix(resource, "outbox_") {
spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")]
return spec, spec != nil
}
spec := internalTenantResourceSpecs[resource]
return spec, spec != nil
}
func usersInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.trusted,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,COALESCE(p.friend_add_enabled,1) AS friend_add_enabled,
COALESCE(
NULLIF(avatar.location,''),
NULLIF(CASE WHEN p.avatar_file_id REGEXP '^(https?://|data:)' THEN p.avatar_file_id ELSE '' END,''),
''
) AS avatar,
p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`,
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id",
TenantColumn: "u.tenant_id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
}
}
func groupsInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux,
gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`,
From: "FROM (SELECT * FROM im_topics WHERE name LIKE 'grp%') AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name",
DefaultSort: "ORDER BY t.id DESC",
DetailKey: "t.name",
TenantColumn: "t.tenant_id",
Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}},
}
}
func privateMessagesInternalQuerySpec() *internalQuerySpec {
rawContentType := `COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$."x-im-type"')),''),NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type')),''),CASE WHEN JSON_EXTRACT(m.content,'$.callId') IS NOT NULL THEN 'call' ELSE 'text' END)`
contentType := `CASE ` + rawContentType + ` WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE ` + rawContentType + ` END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.recalled_at IS NOT NULL THEN 'recalled' WHEN m.deletedat IS NOT NULL THEN 'deleted' ELSE 'normal' END`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.` + "`from`" + ` AS CHAR) AS from_user_id,
COALESCE(su.username,'') AS sender_username,COALESCE(sp.nickname,'') AS sender_nickname,
CAST(rs.userid AS CHAR) AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
` + contentType + ` AS content_type,` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,m.createdat AS created_at,m.updatedat AS updated_at`,
From: `FROM (SELECT * FROM im_messages WHERE topic LIKE 'p2p%') AS m
LEFT JOIN im_users AS su ON su.id=m.` + "`from`" + `
LEFT JOIN im_user_profiles AS sp ON sp.user_id=m.` + "`from`" + `
LEFT JOIN im_subscriptions AS rs ON rs.id=(SELECT MIN(s2.id) FROM im_subscriptions AS s2 WHERE s2.topic=m.topic AND s2.userid<>m.` + "`from`" + `)
LEFT JOIN im_users AS ru ON ru.id=rs.userid
LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
TenantColumn: "m.tenant_id",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}},
}
}
func groupMessagesInternalQuerySpec() *internalQuerySpec {
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.from_user AS CHAR) AS from_user_id,
COALESCE(u.username,'') AS sender_username,COALESCE(p.nickname,'') AS sender_nickname,
COALESCE(gs.name,'') AS group_name,m.topic AS group_no,` + contentType + ` AS content_type,
` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,COALESCE(m.reply_seqid,0) AS reply_to_seq,m.mentioned_user_ids AS at_users,
m.created_at,m.updated_at`,
From: `FROM im_group_messages AS m
LEFT JOIN im_users AS u ON u.id=m.from_user
LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
TenantColumn: "m.tenant_id",
Filters: []internalQueryFilter{
{"topic", "m.topic", "LIKE", "string"},
{"seq_id", "m.seqid", "=", "int"},
{"sender_user_id", "m.from_user", "=", "uid"},
{"sender_username", "u.username", "LIKE", "string"},
{"sender_nickname", "p.nickname", "LIKE", "string"},
{"group_name", "gs.name", "LIKE", "string"},
{"content_type", contentType, "=", "string"},
{"status", status, "=", "string"},
{"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"},
{"created_at_start", "m.created_at", ">=", "time"},
{"created_at_end", "m.created_at", "<=", "time"},
},
}
}
func groupMessagesInternalQuerySpecForTenant(tenantID types.TenantID) (*internalQuerySpec, error) {
source, err := internalGroupMessageSource(tenantID)
if err != nil {
return nil, err
}
spec := groupMessagesInternalQuerySpec()
spec.From = strings.Replace(spec.From, "FROM im_group_messages AS m", "FROM "+source+" AS m", 1)
spec.DefaultSort = "ORDER BY m.created_at DESC,m.id DESC"
return spec, nil
}
func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
shardFilter := "status<>'archived' AND tenant_id=?"
result, err := store.InternalAdminQueries.Query(tenantID,
"SELECT table_name FROM im_group_message_shards WHERE "+shardFilter,
"SELECT COUNT(*) FROM im_group_message_shards WHERE "+shardFilter, []any{int64(tenantID)}, 1, 200)
if err != nil {
return "", err
}
selects := make([]string, 0)
for _, item := range result.Items {
table := strings.TrimSpace(fmt.Sprint(item["table_name"]))
if !validInternalGroupMessageTable(table) {
continue
}
selects = append(selects, fmt.Sprintf(`SELECT message_id AS id,tenant_id,topic,seqid,from_user,msg_type,status,content,head,reply_seqid,mentioned_user_ids,created_at,updated_at FROM %s`, table))
}
// legacy 行内联租户条件(tenantID 来自租户注册表数值 ID,非用户输入)
selects = append(selects, fmt.Sprintf(`SELECT id,tenant_id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
FROM im_group_messages AS legacy WHERE legacy.tenant_id=%d AND NOT EXISTS (
SELECT 1 FROM im_group_message_topic_shards AS route
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`, int64(tenantID)))
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
}
func validInternalGroupMessageTable(table string) bool {
const prefix = "im_group_messages_"
if len(table) != len(prefix)+6 || !strings.HasPrefix(table, prefix) {
return false
}
for _, ch := range table[len(prefix):] {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func serveInternalDashboardOverview(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
groupMessages, err := internalGroupMessageSource(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
query := fmt.Sprintf(`SELECT
(SELECT COUNT(*) FROM im_users WHERE state<>20) AS total_users,
(SELECT COUNT(*) FROM im_users WHERE state=0 AND lastseen>=UTC_DATE()) AS active_users,
(SELECT COUNT(*) FROM im_group_settings WHERE group_status='active') AS total_groups,
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%%' OR name LIKE 'grp%%')) AS total_conversations,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%')+(SELECT COUNT(*) FROM %s AS gm)) AS total_messages,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=UTC_DATE())) AS today_messages`, groupMessages, groupMessages)
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
days := parseIMPositiveInt(req.URL.Query().Get("days"), 7)
if days > 31 {
days = 31
}
groupMessages, err := internalGroupMessageSource(tenantID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
query := fmt.Sprintf(`WITH RECURSIVE dates AS (
SELECT UTC_DATE()-INTERVAL %d DAY AS day
UNION ALL SELECT day+INTERVAL 1 DAY FROM dates WHERE day<UTC_DATE()
) SELECT DATE_FORMAT(d.day,'%%Y-%%m-%%d') AS date,
(SELECT COUNT(*) FROM im_users AS u WHERE u.createdat>=d.day AND u.createdat<d.day+INTERVAL 1 DAY) AS user_count,
((SELECT COUNT(*) FROM im_messages AS pm WHERE pm.topic LIKE 'p2p%%' AND pm.createdat>=d.day AND pm.createdat<d.day+INTERVAL 1 DAY)+
(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
FROM dates AS d ORDER BY d.day ASC`, days-1, groupMessages)
result, err := store.InternalAdminQueries.Query(tenantID, query, fmt.Sprintf("SELECT %d", days), nil, 1, days)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage})
}
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string, tenantID types.TenantID) (string, string, []any, int, int, error) {
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
where := []string{"1=1"}
args := []any{}
// 租户隔离必须先于 scope/filters 注入:共库模式下不注入即跨租户泄漏
if spec.TenantColumn != "" {
where = append(where, spec.TenantColumn+"=?")
args = append(args, int64(tenantID))
}
if spec.ScopeColumn != "" && strings.TrimSpace(scope) != "" {
value, err := internalAdminFilterValue("scope", scope, scopeKindForColumn(spec.ScopeColumn))
if err != nil {
return "", "", nil, 0, 0, err
}
where = append(where, spec.ScopeColumn+"=?")
args = append(args, value)
}
for _, filter := range spec.Filters {
raw := strings.TrimSpace(req.URL.Query().Get(filter.Param))
if raw == "" {
continue
}
value, err := internalAdminFilterValue(filter.Param, raw, filter.Kind)
if err != nil {
return "", "", nil, 0, 0, err
}
if filter.Kind == "uid_list" {
ids := value.([]any)
placeholders := make([]string, len(ids))
for i := range ids {
placeholders[i] = "?"
}
where = append(where, filter.Column+" IN ("+strings.Join(placeholders, ",")+")")
args = append(args, ids...)
continue
}
op := filter.Op
if op == "" {
op = "="
}
if strings.EqualFold(op, "LIKE") {
value = "%" + fmt.Sprint(value) + "%"
}
where = append(where, filter.Column+" "+op+" ?")
args = append(args, value)
}
whereSQL := strings.Join(where, " AND ")
query := spec.Select + " " + spec.From + " WHERE " + whereSQL + " " + spec.DefaultSort
countQuery := "SELECT COUNT(*) " + spec.From + " WHERE " + whereSQL
return query, countQuery, args, page, perPage, nil
}
func internalAdminFilterValue(_ string, raw, kind string) (any, error) {
switch kind {
case "uid":
id, ok := parseInternalAdminDBID(raw)
if !ok {
return nil, types.ErrMalformed
}
return id, nil
case "int":
return strconv.ParseInt(raw, 10, 64)
case "uid_list":
parts := strings.Split(raw, ",")
if len(parts) == 0 || len(parts) > 5000 {
return nil, types.ErrMalformed
}
ids := make([]any, 0, len(parts))
for _, part := range parts {
id, ok := parseInternalAdminDBID(strings.TrimSpace(part))
if !ok {
return nil, types.ErrMalformed
}
ids = append(ids, id)
}
return ids, nil
default:
return raw, nil
}
}
func scopeKindForColumn(column string) string {
switch column {
case "userid", "fileid":
return "uid"
default:
return "string"
}
}
func parseIMPositiveInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
return value
}
func normalizeInternalAdminRowsForAPI(rows []map[string]any) []map[string]any {
for i := range rows {
rows[i] = normalizeInternalAdminRowForAPI(rows[i])
}
return rows
}
func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any {
return row
}
// internalAdminRowString 把 DB 行字段安全转为字符串(driver 可能返回 string 或 []byte)。
func internalAdminRowString(row map[string]any, key string) string {
switch v := row[key].(type) {
case string:
return strings.TrimSpace(v)
case []byte:
return strings.TrimSpace(string(v))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
// attachLoginLockoutState 为登录失败日志的锁定行补充 Redis 实时锁定状态。
// 锁定记录存于 Redis(im_login_lockouts 表已废弃写入),日志行是历史快照不会更新,
// 管理端解锁按钮依赖 locked_now 判断"当前是否仍锁定中",解锁成功后按钮随刷新消失。
func attachLoginLockoutState(tenantID types.TenantID, items []map[string]any) {
now := time.Now()
for _, item := range items {
if item == nil || !strings.EqualFold(internalAdminRowString(item, "risk_action"), loginAuditRiskActionLocked) {
continue
}
accountHash := internalAdminRowString(item, "account_hash")
if accountHash == "" {
continue
}
locked := false
if lockout, err := store.LoginLockouts.Get(tenantID, accountHash); err == nil && lockout != nil &&
lockout.LockedUntil != nil && lockout.LockedUntil.After(now) {
locked = true
}
item["locked_now"] = locked
}
}
func parseInternalAdminDBID(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
return id, true
}
if uid := types.ParseUserId(raw); !uid.IsZero() {
return store.DecodeUid(uid), true
}
if uid := types.ParseUid(raw); !uid.IsZero() {
return store.DecodeUid(uid), true
}
return 0, false
}
func internalAdminUIDRaw(uid types.Uid) int64 {
if uid.IsZero() {
return 0
}
return store.DecodeUid(uid)
}
func internalAdminUIDStringRaw(raw string) any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if id, ok := parseInternalAdminDBID(raw); ok {
return id
}
return raw
}
func numericID(value any) (int64, bool) {
switch v := value.(type) {
case int64:
return v, true
case int:
return int64(v), true
case int32:
return int64(v), true
case uint64:
return int64(v), true
case string:
id, err := strconv.ParseInt(v, 10, 64)
return id, err == nil
default:
return 0, false
}
}