diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index b9f45ef..3d88280 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -81,7 +81,7 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans | system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) | | auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | | users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` | -| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report | +| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report;`GET /v1/friends/lookup?q=` | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | | conversations | `/v1/conversations` | list, mute | | messages | `/v1/messages` | history (sync still prefers TCP) | diff --git a/README.md b/README.md index 5e2e564..0c976e0 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 | 枚举 | `GET /v1/meta/enums` | 无需登录 | | 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 | | 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) | -| 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 | +| 好友 | `/v1/friends` | 申请、同意、查找(`GET /lookup?q=`)、备注、置顶、拉黑、举报 | | 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 | | 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 | | 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` | diff --git a/configs/config.yaml b/configs/config.yaml index 4ec5ff4..c683bcc 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -57,6 +57,10 @@ jwt: denylist_prefix: "denylist:" refresh_prefix: "refresh:" +# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login). +admin: + jwt_secret: "wyrs-admin-jwt-secret-change-in-production-2025" + taskpool: size: 256 queue: 1024 diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 4e0c0f3..a17f11c 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -34,3 +34,15 @@ bash deploy/nginx/install-host.sh ``` `http.conf` / `stream.conf` / `nginx.conf` are a dedicated-box template. `/readyz` is loopback-only on 8443; local probes hit Gin on `127.0.0.1:18080`. + +## popadmin 接入 V3 管理 API + +popadmin 前端仍请求同源 `/api/admin/*`(无需改 `API` 常量)。在 **popadmin.wyrs365.com** 的 Nginx 里,把 IM/群/用户等路径转到 spark-server: + +```bash +sudo cp deploy/nginx/host/popadmin-spark-admin-locations.conf /etc/nginx/conf.d/ +# Aliyun 仓库 nginx-popadmin-wyrs.conf 已 include 该文件 +sudo nginx -t && sudo systemctl reload nginx +``` + +确保 spark-server 容器监听 `127.0.0.1:18080`,且 `admin.jwt_secret` 与 Java `WYRS_ADMIN_JWT_SECRET` 一致。登录、仪表盘、configs/通讯录/相册仍走 Java `:8090`。 diff --git a/deploy/nginx/host/popadmin-spark-admin-locations.conf b/deploy/nginx/host/popadmin-spark-admin-locations.conf new file mode 100644 index 0000000..e37c70f --- /dev/null +++ b/deploy/nginx/host/popadmin-spark-admin-locations.conf @@ -0,0 +1,60 @@ +# Spark IM V3 运营管理 API → spark-server HTTP(默认 127.0.0.1:18080) +# 在 popadmin HTTPS server 里、在 `location /api/admin/` 兜底之前 include 本文件: +# include /etc/nginx/conf.d/popadmin-spark-admin-locations.conf; +# +# 前提:popadmin 与 spark-server 同机;JWT 密钥与 Java WYRS_ADMIN_JWT_SECRET 一致。 + +upstream spark_im_admin { + server 127.0.0.1:18080 max_fails=3 fail_timeout=30s; + keepalive 8; +} + +# 仍由 Java 提供的火花运营接口(勿切到 Go) +location ~ ^/api/admin/(spark|pop)/(configs|contacts|media|media-pull|architecture-modules|bot-feed|geo-access|social-demo|test-accounts|ai-users)(/|$) { + proxy_pass http://popadmin_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Client-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; +} + +location ~ ^/api/admin/spark/users/export-zip { + proxy_pass http://popadmin_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; +} + +# spark-server:IM / 群 / 投诉 / 排行 / 用户检索与 V3 账号运营 +location ~ ^/api/admin/(spark|pop)/(im|groups|complaints|ops-catalog|rankings)(/|$) { + proxy_pass http://spark_im_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Client-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; +} + +location ~ ^/api/admin/(spark|pop)/users/by-(nickname|device|ip)(\?|$) { + proxy_pass http://spark_im_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; +} + +# V3 用户列表与 by-email 运营动作(popadmin 专站;登录/仪表盘仍走 Java) +location ~ ^/api/admin/users(/|$|\?) { + proxy_pass http://spark_im_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Client-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + client_max_body_size 2m; +} diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 46731d5..fcac33d 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -20,6 +20,8 @@ import ( "spark-server/internal/infrastructure/mysql" "spark-server/internal/infrastructure/objectstore" redisx "spark-server/internal/infrastructure/redis" + adminlogic "spark-server/internal/module/admin/logic" + adminmodel "spark-server/internal/module/admin/model" authlogic "spark-server/internal/module/auth/logic" convlogic "spark-server/internal/module/conversation/logic" convmodel "spark-server/internal/module/conversation/model" @@ -173,6 +175,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(idgen, db, objects, friends, users) users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...) + adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen) app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ Addr: cfg.Server.TCP.Addr, Multicore: cfg.Server.TCP.Multicore, @@ -203,12 +206,23 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge Moments: moments, Messages: messages, Sync: syncSvc, + Admin: adminSvc, + AdminTokens: adminTokens, Health: probe, AuthLimiter: authLimit, }) return app, nil } +func wireAdmin(cfg *Config, db *mysql.DB, groups *grouplogic.Service, idgen *id.Generator) (*adminlogic.Service, *adminlogic.TokenService) { + tokens := adminlogic.NewTokenService(cfg.Admin.JWTSecret) + var repo *adminmodel.Repo + if db != nil && db.Ready() { + repo = adminmodel.NewRepo(db.Gorm()) + } + return adminlogic.NewService(repo, groups, idgen), tokens +} + func loadTLS(t TLSConfig) (*tls.Config, error) { if t.CertFile == "" || t.KeyFile == "" { return nil, nil diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index e2e4d1b..42643cd 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -18,6 +18,7 @@ type Config struct { MySQL MySQLConfig `yaml:"mysql"` Redis RedisConfig `yaml:"redis"` JWT JWTConfig `yaml:"jwt"` + Admin AdminConfig `yaml:"admin"` TaskPool TaskPoolConfig `yaml:"taskpool"` Deliver DeliverConfig `yaml:"deliver"` Session SessionConfig `yaml:"session"` @@ -26,6 +27,11 @@ type Config struct { ObjectStorage ObjectStorageConfig `yaml:"object_storage"` } +// AdminConfig is popadmin JWT validation (separate from client jwt). +type AdminConfig struct { + JWTSecret string `yaml:"jwt_secret"` +} + type LogConfig struct { Level string `yaml:"level"` Encoding string `yaml:"encoding"` @@ -185,6 +191,12 @@ func applyDefaults(cfg *Config) { if v := os.Getenv("SPARK_JWT_SECRET"); v != "" { cfg.JWT.Secret = v } + if v := os.Getenv("SPARK_ADMIN_JWT_SECRET"); v != "" { + cfg.Admin.JWTSecret = v + } + if strings.TrimSpace(cfg.Admin.JWTSecret) == "" { + cfg.Admin.JWTSecret = "wyrs-admin-jwt-secret-change-in-production-2025" + } if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" { cfg.Redis.Password = v } @@ -281,6 +293,10 @@ func (cfg *Config) validate() error { if strings.TrimSpace(cfg.JWT.Secret) == "" || strings.TrimSpace(cfg.JWT.Secret) == DefaultJWTSecret { return fmt.Errorf("prod: jwt.secret must be set via config or SPARK_JWT_SECRET") } + if strings.TrimSpace(cfg.Admin.JWTSecret) == "" || + strings.TrimSpace(cfg.Admin.JWTSecret) == "wyrs-admin-jwt-secret-change-in-production-2025" { + return fmt.Errorf("prod: admin.jwt_secret must be set via config or SPARK_ADMIN_JWT_SECRET") + } if ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" { if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" { return fmt.Errorf("prod: object_storage access_key and secret_key are required") diff --git a/internal/bootstrap/health_test.go b/internal/bootstrap/health_test.go index 742c011..b9bb6c5 100644 --- a/internal/bootstrap/health_test.go +++ b/internal/bootstrap/health_test.go @@ -32,6 +32,10 @@ func TestParseEnvAndProductionValidate(t *testing.T) { t.Fatal("prod default jwt") } prod.JWT.Secret = "long-enough-production-secret" + if err := prod.normalize(); err == nil { + t.Fatal("prod default admin jwt") + } + prod.Admin.JWTSecret = "production-admin-jwt-secret-not-default" if err := prod.normalize(); err != nil { t.Fatal(err) } diff --git a/internal/module/admin/entity/view.go b/internal/module/admin/entity/view.go new file mode 100644 index 0000000..1fe0a39 --- /dev/null +++ b/internal/module/admin/entity/view.go @@ -0,0 +1,86 @@ +// Package entity holds admin API shapes for popadmin (not client /v1). +package entity + +// ImStats is GET /im/stats. +type ImStats struct { + MessageCount int64 `json:"messageCount"` + ConversationCount int64 `json:"conversationCount"` + GroupCount int64 `json:"groupCount"` + RevokedCount int64 `json:"revokedCount"` +} + +type ImMessageView struct { + ID string `json:"id"` + ClientMsgID string `json:"clientMsgId,omitempty"` + From int64 `json:"from"` + To int64 `json:"to,omitempty"` + GroupID int64 `json:"groupId,omitempty"` + ChatType string `json:"chatType"` + Type string `json:"type"` + Text string `json:"text"` + Revoked bool `json:"revoked"` + Ts int64 `json:"ts"` +} + +type ImConversationView struct { + Key string `json:"key"` + ChatType string `json:"chatType"` + UserA int64 `json:"userA,omitempty"` + UserB int64 `json:"userB,omitempty"` + GroupID int64 `json:"groupId,omitempty"` + LastMsgID int64 `json:"lastMsgId"` + LastText string `json:"lastText,omitempty"` + UpdatedAt string `json:"updatedAt"` +} + +type GroupView struct { + GroupID string `json:"groupId"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + OwnerID int64 `json:"ownerId"` + MuteAll bool `json:"muteAll"` + MemberCount int `json:"memberCount"` + Active bool `json:"active"` + UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt,omitempty"` + Members []GroupMemberOut `json:"members,omitempty"` + Messages []ImMessageView `json:"messages,omitempty"` +} + +type GroupMemberOut struct { + UserID int64 `json:"userId"` + Email string `json:"email,omitempty"` + Nickname string `json:"nickname,omitempty"` + Role string `json:"role"` +} + +type UserView struct { + ID int64 `json:"id"` + Email string `json:"email"` + Nickname string `json:"nickname"` + SparkID string `json:"sparkId,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + DeviceID string `json:"deviceId,omitempty"` + Channel string `json:"channel,omitempty"` + LastActiveAt string `json:"lastActiveAt,omitempty"` + Banned bool `json:"banned"` + Status int `json:"status"` + CreatedAt string `json:"createdAt,omitempty"` +} + +type SameIPItem struct { + IP string `json:"ip"` + Count int64 `json:"count"` + UserCount int64 `json:"userCount"` + DeviceCount int64 `json:"deviceCount"` +} + +type ComplaintView struct { + ID int64 `json:"id"` + Email string `json:"email"` + Target string `json:"target"` + Reason string `json:"reason"` + Detail string `json:"detail"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` +} diff --git a/internal/module/admin/logic/complaints.go b/internal/module/admin/logic/complaints.go new file mode 100644 index 0000000..e508f71 --- /dev/null +++ b/internal/module/admin/logic/complaints.go @@ -0,0 +1,105 @@ +package logic + +import ( + "context" + "strconv" + "strings" + "time" + + adminmodel "spark-server/internal/module/admin/model" + friendentity "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) ListComplaints(ctx context.Context) ([]ComplaintView, error) { + if !s.ready() { + return nil, ErrNotReady + } + rows, err := s.repo.ListComplaints(ctx, 200) + if err != nil { + return nil, err + } + return complaintViews(rows), nil +} + +func (s *Service) AddComplaint(ctx context.Context, email, target, reason, detail string) (*ComplaintView, error) { + if s.ids == nil || !s.ready() { + return nil, ErrNotReady + } + email = strings.ToLower(strings.TrimSpace(email)) + target = strings.TrimSpace(target) + var userID, targetID int64 + if email != "" { + userID, _ = s.repo.LookupUserIDByEmail(ctx, email) + } + if target != "" { + if id, e := strconv.ParseInt(target, 10, 64); e == nil { + targetID = id + } else { + targetID, _ = s.repo.LookupUserIDByEmail(ctx, strings.ToLower(target)) + } + } + if userID <= 0 || targetID <= 0 { + return nil, errs.ErrInvalidArgument + } + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + reasonCode := parseReportReason(reason) + now := time.Now() + err = s.repo.InsertComplaint(ctx, &friendentity.Report{ + ID: id, UserID: userID, TargetID: targetID, + Reason: reasonCode, Detail: strings.TrimSpace(detail), CreatedAt: now, + }) + if err != nil { + return nil, err + } + return &ComplaintView{ + ID: id, Email: email, Target: target, + Reason: reportReasonName(int(reasonCode)), Detail: detail, + Status: "open", CreatedAt: now.Format(time.RFC3339), + }, nil +} + +func complaintViews(rows []adminmodel.ComplaintRow) []ComplaintView { + out := make([]ComplaintView, 0, len(rows)) + for _, r := range rows { + target := r.TargetMail + if target == "" { + target = strconv.FormatInt(r.TargetID, 10) + } + out = append(out, ComplaintView{ + ID: r.ID, Email: r.Email, Target: target, + Reason: reportReasonName(r.Reason), Detail: r.Detail, + Status: "open", CreatedAt: r.CreatedAt.Format(time.RFC3339), + }) + } + return out +} + +func reportReasonName(r int) string { + switch friendentity.ReportReason(r) { + case friendentity.ReportSpam: + return "spam" + case friendentity.ReportHarassment: + return "harassment" + case friendentity.ReportFake: + return "fake" + default: + return "other" + } +} + +func parseReportReason(raw string) friendentity.ReportReason { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "spam", "垃圾": + return friendentity.ReportSpam + case "2", "harassment", "骚扰": + return friendentity.ReportHarassment + case "3", "fake", "不实身份": + return friendentity.ReportFake + default: + return friendentity.ReportOther + } +} diff --git a/internal/module/admin/logic/groups.go b/internal/module/admin/logic/groups.go new file mode 100644 index 0000000..34210d0 --- /dev/null +++ b/internal/module/admin/logic/groups.go @@ -0,0 +1,77 @@ +package logic + +import ( + "context" + "strconv" + "strings" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) ListGroups(ctx context.Context, q string) ([]GroupView, error) { + if !s.ready() { + return nil, ErrNotReady + } + rows, err := s.repo.ListGroups(ctx, q) + if err != nil { + return nil, err + } + out := make([]GroupView, 0, len(rows)) + for _, r := range rows { + out = append(out, groupSummary(r)) + } + return out, nil +} + +func (s *Service) GroupDetail(ctx context.Context, groupIDStr string) (*GroupView, error) { + if !s.ready() { + return nil, ErrNotReady + } + gid, err := strconv.ParseInt(strings.TrimSpace(groupIDStr), 10, 64) + if err != nil || gid <= 0 { + return nil, errs.ErrNotFound + } + g, err := s.repo.GroupByID(ctx, gid) + if err != nil { + return nil, err + } + view := groupSummary(*g) + members, err := s.repo.ListGroupMembers(ctx, gid) + if err != nil { + return nil, err + } + view.Members = make([]GroupMemberOut, 0, len(members)) + for _, m := range members { + nick := m.Nickname + if nick == "" { + nick = m.UNick + } + view.Members = append(view.Members, GroupMemberOut{ + UserID: m.UserID, Email: m.Email, Nickname: nick, Role: roleName(m.Role), + }) + } + msgs, err := s.repo.ListGroupMessages(ctx, gid, 100) + if err != nil { + return nil, err + } + view.Messages = make([]ImMessageView, 0, len(msgs)) + for _, m := range msgs { + view.Messages = append(view.Messages, messageView(m)) + } + return &view, nil +} + +func (s *Service) DissolveGroup(ctx context.Context, groupIDStr string) error { + if s.groups == nil || !s.ready() { + return ErrNotReady + } + gid, err := strconv.ParseInt(strings.TrimSpace(groupIDStr), 10, 64) + if err != nil || gid <= 0 { + return errs.ErrNotFound + } + ownerID, err := s.repo.ActiveGroupOwner(ctx, gid) + if err != nil { + return err + } + return s.groups.Dissolve(ctx, ownerID, gid) +} diff --git a/internal/module/admin/logic/im.go b/internal/module/admin/logic/im.go new file mode 100644 index 0000000..26b7a58 --- /dev/null +++ b/internal/module/admin/logic/im.go @@ -0,0 +1,86 @@ +package logic + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +func (s *Service) ImStats(ctx context.Context) (*ImStats, error) { + if !s.ready() { + return nil, ErrNotReady + } + return s.repo.ImStats(ctx) +} + +func (s *Service) ListMessages(ctx context.Context, q string, limit int) ([]ImMessageView, error) { + if !s.ready() { + return nil, ErrNotReady + } + if limit <= 0 || limit > 500 { + limit = 100 + } + q = strings.TrimSpace(q) + rows, err := s.repo.ListRecentMessages(ctx, limit*3) + if err != nil { + return nil, err + } + out := make([]ImMessageView, 0, limit) + for _, r := range rows { + v := messageView(r) + if q != "" && !messageMatch(v, q) { + continue + } + out = append(out, v) + if len(out) >= limit { + break + } + } + return out, nil +} + +func (s *Service) ListConversations(ctx context.Context, userID int64) (convs []ImConversationView, groups []GroupView, stats *ImStats, err error) { + if !s.ready() { + return nil, nil, nil, ErrNotReady + } + if userID <= 0 { + stats, err = s.ImStats(ctx) + return nil, nil, stats, err + } + rows, err := s.repo.ListUserConversations(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + convs = make([]ImConversationView, 0, len(rows)) + groups = make([]GroupView, 0) + seenGroup := map[int64]struct{}{} + for _, r := range rows { + chatType := "private" + if r.Type == 2 { + chatType = "group" + } + key := fmt.Sprintf("c:%d", r.ID) + if chatType == "private" { + key = fmt.Sprintf("p2p:%d_%d", r.UserSmall, r.UserBig) + } else if r.GroupID > 0 { + key = fmt.Sprintf("group:%d", r.GroupID) + } + convs = append(convs, ImConversationView{ + Key: key, ChatType: chatType, + UserA: r.UserSmall, UserB: r.UserBig, GroupID: r.GroupID, + LastMsgID: r.LastMsgID, UpdatedAt: r.LastMsgAt.Format(time.RFC3339), + }) + if r.GroupID > 0 { + if _, ok := seenGroup[r.GroupID]; !ok { + seenGroup[r.GroupID] = struct{}{} + gv, gerr := s.GroupDetail(ctx, strconv.FormatInt(r.GroupID, 10)) + if gerr == nil && gv != nil { + groups = append(groups, *gv) + } + } + } + } + return convs, groups, nil, nil +} diff --git a/internal/module/admin/logic/jwt.go b/internal/module/admin/logic/jwt.go new file mode 100644 index 0000000..caa705a --- /dev/null +++ b/internal/module/admin/logic/jwt.go @@ -0,0 +1,83 @@ +package logic + +import ( + "errors" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const TypeAdmin = "admin" + +var ( + ErrInvalidToken = errors.New("invalid admin token") + ErrWrongType = errors.New("token type mismatch") +) + +// Claims matches Java JwtTokenProvider admin tokens (popadmin.wyrs365.com). +type Claims struct { + AdminID int64 `json:"adminId"` + Role string `json:"role"` + Type string `json:"type"` + Permissions string `json:"permissions"` + AllowedSites string `json:"allowedSites"` + SiteRealm string `json:"siteRealm"` + jwt.RegisteredClaims +} + +// TokenService validates HS256 admin JWTs issued by the Java / popadmin login. +type TokenService struct { + secret []byte +} + +func NewTokenService(secret string) *TokenService { + sec := []byte(strings.TrimSpace(secret)) + if len(sec) < 32 { + padded := make([]byte, 32) + copy(padded, sec) + sec = padded + } + return &TokenService{secret: sec} +} + +func (s *TokenService) Parse(tokenStr string) (*Claims, error) { + if s == nil || len(s.secret) == 0 { + return nil, ErrInvalidToken + } + t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, ErrInvalidToken + } + return s.secret, nil + }) + if err != nil { + return nil, ErrInvalidToken + } + claims, ok := t.Claims.(*Claims) + if !ok || !t.Valid { + return nil, ErrInvalidToken + } + if claims.Type != "" && claims.Type != TypeAdmin { + return nil, ErrWrongType + } + return claims, nil +} + +// CreateToken is for tests and local smoke only (production tokens come from Java login). +func (s *TokenService) CreateToken(adminID int64, username, role, permissions string) (string, error) { + now := time.Now() + claims := Claims{ + AdminID: adminID, + Role: role, + Type: TypeAdmin, + Permissions: permissions, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: username, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims) + return tok.SignedString(s.secret) +} diff --git a/internal/module/admin/logic/jwt_test.go b/internal/module/admin/logic/jwt_test.go new file mode 100644 index 0000000..d417242 --- /dev/null +++ b/internal/module/admin/logic/jwt_test.go @@ -0,0 +1,31 @@ +package logic + +import "testing" + +func TestAdminJWTRoundTrip(t *testing.T) { + s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025") + tok, err := s.CreateToken(42, "ops", "SUPER_ADMIN", "users,groups,imMessages") + if err != nil { + t.Fatal(err) + } + claims, err := s.Parse(tok) + if err != nil { + t.Fatal(err) + } + if claims.AdminID != 42 || claims.Type != TypeAdmin || claims.Subject != "ops" { + t.Fatalf("%+v", claims) + } +} + +func TestAdminJWTRejectsWrongType(t *testing.T) { + s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025") + tok, err := s.CreateToken(1, "x", "SUPER_ADMIN", "") + if err != nil { + t.Fatal(err) + } + // tamper by parsing with different secret + bad := NewTokenService("other-secret-other-secret-other!!") + if _, err := bad.Parse(tok); err == nil { + t.Fatal("expected invalid") + } +} diff --git a/internal/module/admin/logic/map.go b/internal/module/admin/logic/map.go new file mode 100644 index 0000000..c84f5f0 --- /dev/null +++ b/internal/module/admin/logic/map.go @@ -0,0 +1,159 @@ +package logic + +import ( + "fmt" + "strconv" + "strings" + "time" + + adminentity "spark-server/internal/module/admin/entity" + adminmodel "spark-server/internal/module/admin/model" + groupentity "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" +) + +func messageView(r adminmodel.MessageRow) adminentity.ImMessageView { + return adminentity.ImMessageView{ + ID: strconv.FormatInt(r.ID, 10), + ClientMsgID: r.ClientMsgID, + From: r.SenderID, + To: r.ReceiverID, + GroupID: r.GroupID, + ChatType: r.ChatType, + Type: msgTypeName(msgentity.Type(r.MessageType)), + Text: previewText(r.Content, msgentity.Type(r.MessageType)), + Revoked: r.Status == int(msgentity.StatusRecalled), + Ts: r.CreatedAt.UnixMilli(), + } +} + +func messageMatch(v adminentity.ImMessageView, q string) bool { + ql := strings.ToLower(q) + if strings.Contains(strings.ToLower(v.Text), ql) { + return true + } + if strings.Contains(strings.ToLower(v.ID), ql) { + return true + } + if strings.Contains(strings.ToLower(v.Type), ql) { + return true + } + if strings.Contains(strconv.FormatInt(v.From, 10), q) { + return true + } + if v.To > 0 && strings.Contains(strconv.FormatInt(v.To, 10), q) { + return true + } + if v.GroupID > 0 && strings.Contains(strconv.FormatInt(v.GroupID, 10), q) { + return true + } + return false +} + +func msgTypeName(t msgentity.Type) string { + switch t { + case msgentity.TypeText: + return "text" + case msgentity.TypeImage: + return "image" + case msgentity.TypeVoice: + return "voice" + case msgentity.TypeVideo: + return "video" + case msgentity.TypeEmoji: + return "emoji" + case msgentity.TypeGIF: + return "gif" + case msgentity.TypeFile: + return "file" + case msgentity.TypeLocation: + return "location" + case msgentity.TypeSystem: + return "system" + default: + return fmt.Sprintf("type_%d", t) + } +} + +func previewText(raw []byte, t msgentity.Type) string { + c, err := msgentity.UnmarshalContent(raw) + if err != nil || c == nil { + return "" + } + switch t { + case msgentity.TypeText: + if c.Text != nil { + return c.Text.Body + } + case msgentity.TypeImage: + return "[图片]" + case msgentity.TypeVoice: + return "[语音]" + case msgentity.TypeVideo: + return "[视频]" + case msgentity.TypeEmoji: + return "[表情]" + case msgentity.TypeGIF: + return "[GIF]" + case msgentity.TypeFile: + if c.File != nil && c.File.Name != "" { + return "[文件] " + c.File.Name + } + return "[文件]" + case msgentity.TypeLocation: + if c.Location != nil && c.Location.Name != "" { + return "[位置] " + c.Location.Name + } + return "[位置]" + case msgentity.TypeSystem: + if c.System != nil { + if c.System.Body != "" { + return c.System.Body + } + return c.System.Kind + } + } + return "" +} + +func userView(r adminmodel.UserRow) adminentity.UserView { + v := adminentity.UserView{ + ID: r.ID, Email: r.Email, Nickname: r.Nickname, SparkID: r.SparkID, + AvatarURL: r.Avatar, Channel: r.Platform, Status: r.Status, + Banned: r.Status == int(userentity.StatusDisabled), + CreatedAt: r.CreatedAt.Format(time.RFC3339), + } + if r.DeviceID > 0 { + v.DeviceID = strconv.FormatUint(r.DeviceID, 10) + } + if r.LastActiveAt != nil { + v.LastActiveAt = r.LastActiveAt.Format(time.RFC3339) + } + return v +} + +func groupSummary(r adminmodel.GroupRow) adminentity.GroupView { + return adminentity.GroupView{ + GroupID: strconv.FormatInt(r.ID, 10), + Name: r.Name, + Avatar: r.Avatar, + OwnerID: r.OwnerID, + MuteAll: r.MutedAll != 0, + MemberCount: r.MemberCount, + Active: r.Status == int(groupentity.StatusActive), + UpdatedAt: r.UpdatedAt.Format(time.RFC3339), + CreatedAt: r.CreatedAt.Format(time.RFC3339), + } +} + +func roleName(role int) string { + switch groupentity.Role(role) { + case groupentity.RoleOwner: + return "owner" + case groupentity.RoleAdmin: + return "admin" + default: + return "member" + } +} diff --git a/internal/module/admin/logic/service.go b/internal/module/admin/logic/service.go new file mode 100644 index 0000000..82f4453 --- /dev/null +++ b/internal/module/admin/logic/service.go @@ -0,0 +1,30 @@ +// Package logic is the admin application layer (popadmin ops over V3 data). +package logic + +import ( + "errors" + + adminmodel "spark-server/internal/module/admin/model" + grouplogic "spark-server/internal/module/group/logic" +) + +var ErrNotReady = errors.New("admin: repository not ready") + +// Service orchestrates admin use-cases. SQL lives in model.Repo. +type Service struct { + repo *adminmodel.Repo + groups *grouplogic.Service + ids IDGen +} + +type IDGen interface { + NextID() (int64, error) +} + +func NewService(repo *adminmodel.Repo, groups *grouplogic.Service, ids IDGen) *Service { + return &Service{repo: repo, groups: groups, ids: ids} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil +} diff --git a/internal/module/admin/logic/types.go b/internal/module/admin/logic/types.go new file mode 100644 index 0000000..b47352f --- /dev/null +++ b/internal/module/admin/logic/types.go @@ -0,0 +1,15 @@ +package logic + +import adminentity "spark-server/internal/module/admin/entity" + +// Re-export API DTOs for handlers (popadmin JSON shapes). +type ( + ImStats = adminentity.ImStats + ImMessageView = adminentity.ImMessageView + ImConversationView = adminentity.ImConversationView + GroupView = adminentity.GroupView + GroupMemberOut = adminentity.GroupMemberOut + UserView = adminentity.UserView + SameIPItem = adminentity.SameIPItem + ComplaintView = adminentity.ComplaintView +) diff --git a/internal/module/admin/logic/users.go b/internal/module/admin/logic/users.go new file mode 100644 index 0000000..98a5dc5 --- /dev/null +++ b/internal/module/admin/logic/users.go @@ -0,0 +1,171 @@ +package logic + +import ( + "context" + "strconv" + "strings" + + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) ListUsers(ctx context.Context, email string, page, size int) (list []UserView, total int64, err error) { + if !s.ready() { + return nil, 0, ErrNotReady + } + if size <= 0 || size > 200 { + size = 50 + } + if page < 0 { + page = 0 + } + rows, total, err := s.repo.ListUsers(ctx, email, page, size) + if err != nil { + return nil, 0, err + } + list = make([]UserView, 0, len(rows)) + for _, r := range rows { + list = append(list, userView(r)) + } + return list, total, nil +} + +func (s *Service) UserByEmail(ctx context.Context, email string) (map[string]any, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, errs.ErrInvalidArgument + } + if !s.ready() { + return nil, ErrNotReady + } + rows, err := s.repo.UsersByEmail(ctx, email) + if err != nil { + return nil, err + } + items := make([]UserView, 0, len(rows)) + for _, r := range rows { + items = append(items, userView(r)) + } + return map[string]any{ + "email": email, "appUsers": items, "appCode": "spark", + }, nil +} + +func (s *Service) UserByID(ctx context.Context, id int64) (*UserView, error) { + if !s.ready() { + return nil, ErrNotReady + } + r, err := s.repo.UserByID(ctx, id) + if err != nil { + return nil, err + } + v := userView(*r) + return &v, nil +} + +func (s *Service) UsersByNickname(ctx context.Context, nickname string) ([]UserView, error) { + if !s.ready() { + return nil, ErrNotReady + } + rows, err := s.repo.UsersByNickname(ctx, nickname) + if err != nil { + return nil, err + } + out := make([]UserView, 0, len(rows)) + for _, r := range rows { + out = append(out, userView(r)) + } + return out, nil +} + +func (s *Service) UsersByDevice(ctx context.Context, deviceID string) ([]UserView, error) { + if !s.ready() { + return nil, ErrNotReady + } + deviceID = strings.TrimSpace(deviceID) + did, _ := strconv.ParseUint(deviceID, 10, 64) + rows, err := s.repo.UsersByDevice(ctx, deviceID, did) + if err != nil { + return nil, err + } + out := make([]UserView, 0, len(rows)) + for _, r := range rows { + out = append(out, userView(r)) + } + return out, nil +} + +func (s *Service) UsersByIP(ctx context.Context, ip string) ([]UserView, error) { + if !s.ready() { + return nil, ErrNotReady + } + rows, err := s.repo.UsersByIP(ctx, strings.TrimSpace(ip)) + if err != nil { + return nil, err + } + out := make([]UserView, 0, len(rows)) + for _, r := range rows { + out = append(out, userView(r)) + } + return out, nil +} + +func (s *Service) BanByEmail(ctx context.Context, email string, banned bool) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return errs.ErrInvalidArgument + } + if !s.ready() { + return ErrNotReady + } + status := userentity.StatusActive + if banned { + status = userentity.StatusDisabled + } + return s.repo.SetUserStatusByEmail(ctx, email, status) +} + +func (s *Service) ResetNicknameByEmail(ctx context.Context, email, nickname string) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return errs.ErrInvalidArgument + } + if strings.TrimSpace(nickname) == "" { + nickname = "用户" + } + if !s.ready() { + return ErrNotReady + } + return s.repo.SetNicknameByEmail(ctx, email, nickname) +} + +func (s *Service) ResetAvatarByEmail(ctx context.Context, email string) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return errs.ErrInvalidArgument + } + if !s.ready() { + return ErrNotReady + } + return s.repo.ClearAvatarByEmail(ctx, email) +} + +func (s *Service) SameIPRank(ctx context.Context, limit int) ([]SameIPItem, error) { + if !s.ready() { + return nil, ErrNotReady + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.repo.SameIPRank(ctx, limit) + if err != nil { + return nil, err + } + out := make([]SameIPItem, 0, len(rows)) + for _, r := range rows { + out = append(out, SameIPItem{ + IP: r.IP, UserCount: r.UserCount, DeviceCount: r.DeviceCount, Count: r.UserCount, + }) + } + return out, nil +} diff --git a/internal/module/admin/model/complaints.go b/internal/module/admin/model/complaints.go new file mode 100644 index 0000000..1066d98 --- /dev/null +++ b/internal/module/admin/model/complaints.go @@ -0,0 +1,58 @@ +package model + +import ( + "context" + "time" + + friendentity "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) ListComplaints(ctx context.Context, limit int) ([]ComplaintRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + type scanRow struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + TargetID int64 `gorm:"column:target_id"` + Reason int `gorm:"column:reason"` + Detail string `gorm:"column:detail"` + CreatedAt time.Time `gorm:"column:created_at"` + Email string `gorm:"column:email"` + TargetMail string `gorm:"column:target_email"` + } + var rows []scanRow + err = db.Raw(` +SELECT r.id, r.user_id, r.target_id, r.reason, r.detail, r.created_at, + COALESCE(e.email,'') AS email, COALESCE(te.email,'') AS target_email +FROM friend_reports r +LEFT JOIN user_emails e ON e.user_id = r.user_id +LEFT JOIN user_emails te ON te.user_id = r.target_id +ORDER BY r.id DESC +LIMIT ?`, limit).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]ComplaintRow, 0, len(rows)) + for _, r := range rows { + out = append(out, ComplaintRow{ + ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, Reason: r.Reason, + Detail: r.Detail, CreatedAt: r.CreatedAt, Email: r.Email, TargetMail: r.TargetMail, + }) + } + return out, nil +} + +func (m *Repo) InsertComplaint(ctx context.Context, r *friendentity.Report) error { + if r == nil || r.UserID <= 0 || r.TargetID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return mapDBErr(db.Exec(`INSERT INTO friend_reports (id, user_id, target_id, reason, detail, created_at) VALUES (?,?,?,?,?,?)`, + r.ID, r.UserID, r.TargetID, int(r.Reason), r.Detail, r.CreatedAt).Error) +} diff --git a/internal/module/admin/model/groups.go b/internal/module/admin/model/groups.go new file mode 100644 index 0000000..de40b2a --- /dev/null +++ b/internal/module/admin/model/groups.go @@ -0,0 +1,124 @@ +package model + +import ( + "context" + "strconv" + "strings" + "time" + + groupentity "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +type groupScanRow struct { + ID int64 `gorm:"column:id"` + ConversationID int64 `gorm:"column:conversation_id"` + Name string `gorm:"column:name"` + Avatar string `gorm:"column:avatar"` + OwnerID int64 `gorm:"column:owner_id"` + MutedAll int `gorm:"column:muted_all"` + MemberCount int `gorm:"column:member_count"` + Status int `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func groupScanToRow(r groupScanRow) GroupRow { + return GroupRow{ + ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, + OwnerID: r.OwnerID, MutedAll: r.MutedAll, MemberCount: r.MemberCount, Status: r.Status, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } +} + +func (m *Repo) ListGroups(ctx context.Context, q string) ([]GroupRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + query := db.Table("`groups`").Where("status = ?", int(groupentity.StatusActive)) + q = strings.TrimSpace(q) + if q != "" { + like := "%" + q + "%" + if id, e := strconv.ParseInt(q, 10, 64); e == nil { + query = query.Where("id = ? OR name LIKE ?", id, like) + } else { + query = query.Where("name LIKE ?", like) + } + } + var rows []groupScanRow + if err := query.Order("updated_at DESC").Limit(200).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + out := make([]GroupRow, 0, len(rows)) + for _, r := range rows { + out = append(out, groupScanToRow(r)) + } + return out, nil +} + +func (m *Repo) GroupByID(ctx context.Context, groupID int64) (*GroupRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var r groupScanRow + if err := db.Table("`groups`").Where("id = ?", groupID).Take(&r).Error; err != nil { + return nil, mapDBErr(err) + } + row := groupScanToRow(r) + return &row, nil +} + +func (m *Repo) ActiveGroupOwner(ctx context.Context, groupID int64) (int64, error) { + db, err := m.session(ctx) + if err != nil { + return 0, err + } + var r struct { + OwnerID int64 `gorm:"column:owner_id"` + } + err = db.Table("`groups`").Select("owner_id"). + Where("id = ? AND status = ?", groupID, int(groupentity.StatusActive)). + Take(&r).Error + if err != nil { + return 0, mapDBErr(err) + } + if r.OwnerID <= 0 { + return 0, errs.ErrNotFound + } + return r.OwnerID, nil +} + +func (m *Repo) ListGroupMembers(ctx context.Context, groupID int64) ([]GroupMemberRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + type scanRow struct { + UserID int64 `gorm:"column:user_id"` + Role int `gorm:"column:role"` + Nickname string `gorm:"column:nickname"` + Email string `gorm:"column:email"` + UNick string `gorm:"column:u_nickname"` + } + var rows []scanRow + err = db.Raw(` +SELECT m.user_id, m.role, m.nickname, COALESCE(e.email,'') AS email, COALESCE(u.nickname,'') AS u_nickname +FROM group_members m +LEFT JOIN users u ON u.id = m.user_id +LEFT JOIN user_emails e ON e.user_id = m.user_id +WHERE m.group_id = ? +ORDER BY m.role ASC, m.joined_at ASC +LIMIT 500`, groupID).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]GroupMemberRow, 0, len(rows)) + for _, r := range rows { + out = append(out, GroupMemberRow{ + UserID: r.UserID, Role: r.Role, Nickname: r.Nickname, Email: r.Email, UNick: r.UNick, + }) + } + return out, nil +} diff --git a/internal/module/admin/model/im.go b/internal/module/admin/model/im.go new file mode 100644 index 0000000..c3dcea8 --- /dev/null +++ b/internal/module/admin/model/im.go @@ -0,0 +1,143 @@ +package model + +import ( + "context" + "time" + + adminentity "spark-server/internal/module/admin/entity" + msgentity "spark-server/internal/module/message/entity" +) + +func (m *Repo) ImStats(ctx context.Context) (*adminentity.ImStats, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + out := &adminentity.ImStats{} + var priv, grp, revokedPriv, revokedGrp int64 + _ = db.Table("private_message").Count(&priv).Error + _ = db.Table("group_message").Count(&grp).Error + _ = db.Table("private_message").Where("status = ?", msgentity.StatusRecalled).Count(&revokedPriv).Error + _ = db.Table("group_message").Where("status = ?", msgentity.StatusRecalled).Count(&revokedGrp).Error + out.MessageCount = priv + grp + out.RevokedCount = revokedPriv + revokedGrp + _ = db.Table("conversations").Count(&out.ConversationCount).Error + _ = db.Table("`groups`").Where("status = ?", 1).Count(&out.GroupCount).Error + return out, nil +} + +func (m *Repo) ListRecentMessages(ctx context.Context, fetchLimit int) ([]MessageRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + sql := ` +SELECT id, conversation_id, sender_id, receiver_id, 0 AS group_id, client_msg_id, message_type, content, status, created_at, 'private' AS chat_type +FROM private_message +UNION ALL +SELECT id, conversation_id, sender_id, 0 AS receiver_id, group_id, client_msg_id, message_type, content, status, created_at, 'group' AS chat_type +FROM group_message` + type scanRow struct { + ID int64 `gorm:"column:id"` + ConversationID int64 `gorm:"column:conversation_id"` + SenderID int64 `gorm:"column:sender_id"` + ReceiverID int64 `gorm:"column:receiver_id"` + GroupID int64 `gorm:"column:group_id"` + ClientMsgID string `gorm:"column:client_msg_id"` + MessageType int16 `gorm:"column:message_type"` + Content []byte `gorm:"column:content"` + Status int `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + ChatType string `gorm:"column:chat_type"` + } + var rows []scanRow + err = db.Raw(`SELECT * FROM (`+sql+`) AS m ORDER BY created_at DESC LIMIT ?`, fetchLimit).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]MessageRow, 0, len(rows)) + for _, r := range rows { + out = append(out, MessageRow{ + ID: r.ID, ConversationID: r.ConversationID, SenderID: r.SenderID, + ReceiverID: r.ReceiverID, GroupID: r.GroupID, ClientMsgID: r.ClientMsgID, + MessageType: r.MessageType, Content: r.Content, Status: r.Status, + CreatedAt: r.CreatedAt, ChatType: r.ChatType, + }) + } + return out, nil +} + +func (m *Repo) ListUserConversations(ctx context.Context, userID int64) ([]ConversationRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + type scanRow struct { + ID int64 `gorm:"column:id"` + Type int `gorm:"column:type"` + LastMsgID int64 `gorm:"column:last_msg_id"` + LastMsgAt time.Time `gorm:"column:last_msg_at"` + UserSmall int64 `gorm:"column:user_small"` + UserBig int64 `gorm:"column:user_big"` + GroupID int64 `gorm:"column:group_id"` + GroupName string `gorm:"column:group_name"` + } + var rows []scanRow + err = db.Raw(` +SELECT c.id, c.type, c.last_msg_id, c.last_msg_at, + COALESCE(p.user_small, 0) AS user_small, COALESCE(p.user_big, 0) AS user_big, + COALESCE(g.id, 0) AS group_id, COALESCE(g.name, '') AS group_name +FROM conversation_members cm +INNER JOIN conversations c ON c.id = cm.conversation_id +LEFT JOIN p2p_conversations p ON p.conversation_id = c.id +LEFT JOIN `+"`groups`"+` g ON g.conversation_id = c.id +WHERE cm.user_id = ? +ORDER BY c.last_msg_at DESC +LIMIT 200`, userID).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]ConversationRow, 0, len(rows)) + for _, r := range rows { + out = append(out, ConversationRow{ + ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt, + UserSmall: r.UserSmall, UserBig: r.UserBig, GroupID: r.GroupID, GroupName: r.GroupName, + }) + } + return out, nil +} + +func (m *Repo) ListGroupMessages(ctx context.Context, groupID int64, limit int) ([]MessageRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + type scanRow struct { + ID int64 `gorm:"column:id"` + ConversationID int64 `gorm:"column:conversation_id"` + SenderID int64 `gorm:"column:sender_id"` + GroupID int64 `gorm:"column:group_id"` + ClientMsgID string `gorm:"column:client_msg_id"` + MessageType int16 `gorm:"column:message_type"` + Content []byte `gorm:"column:content"` + Status int `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + ChatType string `gorm:"column:chat_type"` + } + var rows []scanRow + err = db.Raw(` +SELECT id, conversation_id, sender_id, group_id, client_msg_id, message_type, content, status, created_at, 'group' AS chat_type +FROM group_message WHERE group_id = ? ORDER BY created_at DESC LIMIT ?`, groupID, limit).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]MessageRow, 0, len(rows)) + for _, r := range rows { + out = append(out, MessageRow{ + ID: r.ID, ConversationID: r.ConversationID, SenderID: r.SenderID, GroupID: r.GroupID, + ClientMsgID: r.ClientMsgID, MessageType: r.MessageType, Content: r.Content, + Status: r.Status, CreatedAt: r.CreatedAt, ChatType: r.ChatType, + }) + } + return out, nil +} diff --git a/internal/module/admin/model/repo.go b/internal/module/admin/model/repo.go new file mode 100644 index 0000000..824972e --- /dev/null +++ b/internal/module/admin/model/repo.go @@ -0,0 +1,41 @@ +package model + +import ( + "context" + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/pkg/errs" +) + +// Repo is admin read/write queries over Spark IM V3 tables (popadmin ops). +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return err +} diff --git a/internal/module/admin/model/row.go b/internal/module/admin/model/row.go new file mode 100644 index 0000000..12f922e --- /dev/null +++ b/internal/module/admin/model/row.go @@ -0,0 +1,73 @@ +package model + +import "time" + +type MessageRow struct { + ID int64 + ConversationID int64 + SenderID int64 + ReceiverID int64 + GroupID int64 + ClientMsgID string + MessageType int16 + Content []byte + Status int + CreatedAt time.Time + ChatType string +} + +type ConversationRow struct { + ID int64 + Type int + LastMsgID int64 + LastMsgAt time.Time + UserSmall int64 + UserBig int64 + GroupID int64 + GroupName string +} + +type GroupRow struct { + ID int64 + ConversationID int64 + Name string + Avatar string + OwnerID int64 + MutedAll int + MemberCount int + Status int + CreatedAt time.Time + UpdatedAt time.Time +} + +type GroupMemberRow struct { + UserID int64 + Role int + Nickname string + Email string + UNick string +} + +type UserRow struct { + ID int64 + SparkID string + Nickname string + Avatar string + Status int + LastActiveAt *time.Time + CreatedAt time.Time + Email string + DeviceID uint64 + Platform string +} + +type ComplaintRow struct { + ID int64 + UserID int64 + TargetID int64 + Reason int + Detail string + CreatedAt time.Time + Email string + TargetMail string +} diff --git a/internal/module/admin/model/users.go b/internal/module/admin/model/users.go new file mode 100644 index 0000000..c13378d --- /dev/null +++ b/internal/module/admin/model/users.go @@ -0,0 +1,266 @@ +package model + +import ( + "context" + "strings" + "time" + + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) ListUsers(ctx context.Context, emailLike string, page, size int) ([]UserRow, int64, error) { + db, err := m.session(ctx) + if err != nil { + return nil, 0, err + } + base := db.Table("users AS u").Joins("LEFT JOIN user_emails e ON e.user_id = u.id") + emailLike = strings.TrimSpace(emailLike) + if emailLike != "" { + base = base.Where("e.email LIKE ?", "%"+emailLike+"%") + } + var total int64 + _ = base.Count(&total).Error + var rows []userScanRow + if err := base.Select(userListSelect).Order("u.id DESC").Offset(page * size).Limit(size).Scan(&rows).Error; err != nil { + return nil, 0, mapDBErr(err) + } + return userScanToRows(rows), total, nil +} + +const userListSelect = `u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + COALESCE(e.email,'') AS email, + COALESCE((SELECT d.device_id FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), 0) AS device_id, + COALESCE((SELECT d.platform FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), '') AS platform` + +func (m *Repo) UsersByEmail(ctx context.Context, email string) ([]UserRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []userScanRow + err = db.Raw(` +SELECT u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + e.email, + COALESCE((SELECT d.device_id FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), 0) AS device_id, + COALESCE((SELECT d.platform FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), '') AS platform +FROM user_emails e +INNER JOIN users u ON u.id = e.user_id +WHERE e.email = ?`, email).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + return userScanToRows(rows), nil +} + +func (m *Repo) UserByID(ctx context.Context, id int64) (*UserRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []userScanRow + err = db.Raw(` +SELECT u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + COALESCE(e.email,'') AS email, + COALESCE((SELECT d.device_id FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), 0) AS device_id, + COALESCE((SELECT d.platform FROM user_devices d WHERE d.user_id = u.id ORDER BY d.last_login_at DESC LIMIT 1), '') AS platform +FROM users u +LEFT JOIN user_emails e ON e.user_id = u.id +WHERE u.id = ?`, id).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + if len(rows) == 0 || rows[0].ID == 0 { + return nil, errs.ErrNotFound + } + r := userScanToRow(rows[0]) + return &r, nil +} + +func (m *Repo) UsersByNickname(ctx context.Context, nickname string) ([]UserRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []userScanRow + err = db.Raw(` +SELECT u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + COALESCE(e.email,'') AS email, 0 AS device_id, '' AS platform +FROM users u +LEFT JOIN user_emails e ON e.user_id = u.id +WHERE u.nickname LIKE ? +ORDER BY u.id DESC LIMIT 50`, "%"+strings.TrimSpace(nickname)+"%").Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + return userScanToRows(rows), nil +} + +func (m *Repo) UsersByDevice(ctx context.Context, deviceID string, deviceIDNum uint64) ([]UserRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []userScanRow + err = db.Raw(` +SELECT u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + COALESCE(e.email,'') AS email, d.device_id, d.platform +FROM user_devices d +INNER JOIN users u ON u.id = d.user_id +LEFT JOIN user_emails e ON e.user_id = u.id +WHERE CAST(d.device_id AS CHAR) = ? OR d.device_id = ? +ORDER BY d.last_login_at DESC LIMIT 50`, deviceID, deviceIDNum).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + return userScanToRows(rows), nil +} + +func (m *Repo) UsersByIP(ctx context.Context, ip string) ([]UserRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []userScanRow + err = db.Raw(` +SELECT DISTINCT u.id, u.spark_id, u.nickname, u.avatar, u.status, u.last_active_at, u.created_at, + COALESCE(e.email,'') AS email, d.device_id, d.platform +FROM user_devices d +INNER JOIN users u ON u.id = d.user_id +LEFT JOIN user_emails e ON e.user_id = u.id +WHERE d.last_ip = ? +ORDER BY u.id DESC LIMIT 100`, ip).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + return userScanToRows(rows), nil +} + +func (m *Repo) SetUserStatusByEmail(ctx context.Context, email string, status userentity.Status) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Exec(` +UPDATE users u +INNER JOIN user_emails e ON e.user_id = u.id +SET u.status = ?, u.updated_at = ? +WHERE e.email = ?`, int(status), time.Now(), email) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) SetNicknameByEmail(ctx context.Context, email, nickname string) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Exec(` +UPDATE users u +INNER JOIN user_emails e ON e.user_id = u.id +SET u.nickname = ?, u.updated_at = ? +WHERE e.email = ?`, nickname, time.Now(), email) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) ClearAvatarByEmail(ctx context.Context, email string) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Exec(` +UPDATE users u +INNER JOIN user_emails e ON e.user_id = u.id +SET u.avatar = '', u.updated_at = ? +WHERE e.email = ?`, time.Now(), email) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +type SameIPRankRow struct { + IP string + UserCount int64 + DeviceCount int64 +} + +func (m *Repo) SameIPRank(ctx context.Context, limit int) ([]SameIPRankRow, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []struct { + IP string `gorm:"column:ip"` + UserCount int64 `gorm:"column:user_count"` + DeviceCount int64 `gorm:"column:device_count"` + } + err = db.Raw(` +SELECT last_ip AS ip, COUNT(DISTINCT user_id) AS user_count, COUNT(*) AS device_count +FROM user_devices +WHERE last_ip IS NOT NULL AND last_ip <> '' AND last_ip NOT LIKE '127.%' AND last_ip <> '::1' +GROUP BY last_ip +ORDER BY user_count DESC +LIMIT ?`, limit).Scan(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]SameIPRankRow, len(rows)) + for i, r := range rows { + out[i] = SameIPRankRow{IP: r.IP, UserCount: r.UserCount, DeviceCount: r.DeviceCount} + } + return out, nil +} + +func (m *Repo) LookupUserIDByEmail(ctx context.Context, email string) (int64, error) { + db, err := m.session(ctx) + if err != nil { + return 0, err + } + var id int64 + err = db.Table("user_emails").Select("user_id").Where("email = ?", email).Scan(&id).Error + return id, mapDBErr(err) +} + +type userScanRow struct { + ID int64 `gorm:"column:id"` + SparkID string `gorm:"column:spark_id"` + Nickname string `gorm:"column:nickname"` + Avatar string `gorm:"column:avatar"` + Status int `gorm:"column:status"` + LastActiveAt *time.Time `gorm:"column:last_active_at"` + CreatedAt time.Time `gorm:"column:created_at"` + Email string `gorm:"column:email"` + DeviceID uint64 `gorm:"column:device_id"` + Platform string `gorm:"column:platform"` +} + +func userScanToRows(rows []userScanRow) []UserRow { + out := make([]UserRow, 0, len(rows)) + for _, r := range rows { + out = append(out, userScanToRow(r)) + } + return out +} + +func userScanToRow(r userScanRow) UserRow { + return UserRow{ + ID: r.ID, SparkID: r.SparkID, Nickname: r.Nickname, Avatar: r.Avatar, + Status: r.Status, LastActiveAt: r.LastActiveAt, CreatedAt: r.CreatedAt, + Email: r.Email, DeviceID: r.DeviceID, Platform: r.Platform, + } +} diff --git a/internal/module/friend/logic/apply.go b/internal/module/friend/logic/apply.go index 851b204..6a890c9 100644 --- a/internal/module/friend/logic/apply.go +++ b/internal/module/friend/logic/apply.go @@ -18,7 +18,7 @@ import ( type ApplyInput struct { UID int64 `json:"uid" validate:"omitempty,gt=0" label:"field.uid"` Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"` - SparkID string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + SparkID string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id"` QRCode string `json:"qrcode" validate:"omitempty,max=64" label:"field.qrcode"` Reason string `json:"reason" validate:"omitempty,max=128" label:"field.reason" errmsg:"max:validate.friend.reason_invalid"` Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"` diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go index c4fb8af..ab4a84d 100644 --- a/internal/module/friend/logic/friend_test.go +++ b/internal/module/friend/logic/friend_test.go @@ -3,6 +3,7 @@ package logic import ( "context" "errors" + "strconv" "testing" "spark-server/internal/infrastructure/id" @@ -207,7 +208,7 @@ func TestRemarkReportLookup(t *testing.T) { svc, users, _ := testFriends(t) ctx := t.Context() a, b := twoUsers(t, users) - look, err := svc.Lookup(ctx, a.ID, LookupInput{SparkID: b.SparkID}) + look, err := svc.Lookup(ctx, a.ID, LookupInput{Q: b.SparkID}) if err != nil || look.UID != b.ID || look.Relation != entity.RelationNone { t.Fatalf("lookup %+v %v", look, err) } @@ -230,6 +231,30 @@ func TestRemarkReportLookup(t *testing.T) { } } +func TestLookupQueryClassifies(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + queries := []string{ + "test02@spark.com", + strconv.FormatInt(b.ID, 10), + b.SparkID, + b.QRCode, + } + for _, q := range queries { + look, err := svc.Lookup(ctx, a.ID, LookupInput{Q: q}) + if err != nil || look.UID != b.ID { + t.Fatalf("q=%q %+v %v", q, look, err) + } + } + if _, err := svc.Lookup(ctx, a.ID, LookupInput{Q: ""}); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("empty %v", err) + } + if _, err := svc.Lookup(ctx, a.ID, LookupInput{Q: "nobody_xx"}); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("missing %v", err) + } +} + func TestSystemGreeterWritesMessage(t *testing.T) { ids, err := id.New(1) if err != nil { diff --git a/internal/module/friend/logic/lookup.go b/internal/module/friend/logic/lookup.go index a9ff190..8fa1d88 100644 --- a/internal/module/friend/logic/lookup.go +++ b/internal/module/friend/logic/lookup.go @@ -3,26 +3,32 @@ package logic import ( "context" "errors" + "regexp" + "strconv" + "strings" "spark-server/internal/module/friend/entity" + userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" ) -// LookupInput finds a user to add. Exactly one identifier. +var ( + lookupEmailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + lookupDigitsRe = regexp.MustCompile(`^[0-9]+$`) +) + +// LookupInput finds a user to add. Q is email, uid, spark_id, or qrcode. type LookupInput struct { - UID int64 - Email string - SparkID string - QRCode string + Q string `json:"q" validate:"required,max=255" label:"field.friend_query" errmsg:"required:validate.friend.query_required"` } func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*FriendView, error) { if !s.ready() || userID <= 0 { return nil, errs.ErrUnauthorized } - target, _, err := s.resolveTarget(ctx, &ApplyInput{ - UID: in.UID, Email: in.Email, SparkID: in.SparkID, QRCode: in.QRCode, - }) + target, err := s.resolveLookupQuery(ctx, strings.TrimSpace(in.Q)) if err != nil { return nil, err } @@ -56,3 +62,37 @@ func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*Fr } return view, nil } + +func (s *Service) resolveLookupQuery(ctx context.Context, q string) (*userentity.User, error) { + if q == "" { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendQueryRequired, nil)) + } + switch { + case lookupEmailRe.MatchString(q): + u, err := s.users.GetByEmail(ctx, userentity.NormalizeEmail(q)) + u, _, err = s.ensureActive(ctx, u, err, entity.SourceEmail) + return u, err + case lookupDigitsRe.MatchString(q): + id, err := strconv.ParseInt(q, 10, 64) + if err != nil || id <= 0 { + return nil, errs.ErrNotFound + } + return s.activeUser(ctx, id) + default: + return s.lookupSparkIDThenQR(ctx, q) + } +} + +func (s *Service) lookupSparkIDThenQR(ctx context.Context, q string) (*userentity.User, error) { + u, err := s.users.GetBySparkID(ctx, q) + u, _, err = s.ensureActive(ctx, u, err, entity.SourceSparkID) + if err == nil { + return u, nil + } + if !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + u, err = s.users.GetByQRCode(ctx, q) + u, _, err = s.ensureActive(ctx, u, err, entity.SourceQRCode) + return u, err +} diff --git a/internal/module/user/entity/spark_id.go b/internal/module/user/entity/spark_id.go index 6072a69..535154b 100644 --- a/internal/module/user/entity/spark_id.go +++ b/internal/module/user/entity/spark_id.go @@ -2,37 +2,43 @@ package entity import ( "regexp" - "strings" ) const ( - SparkIDMin = 6 - SparkIDMax = 32 + SparkIDMin = 8 + SparkIDMax = 16 + // sparkIDBodyLen is 13: int64 fits in 13 base36 digits (36^13 > 2^63). + sparkIDBodyLen = 13 + sparkIDPrefix = 's' ) -var sparkIDRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{5,31}$`) +var sparkIDRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{7,15}$`) +// ValidSparkID checks a new or updated handle. Existing rows may still be 6-32. func ValidSparkID(id string) bool { n := len(id) return n >= SparkIDMin && n <= SparkIDMax && sparkIDRe.MatchString(id) } -// DefaultSparkID is a unique, letter-prefixed handle derived from the Snowflake id. +// DefaultSparkID maps a Snowflake uid to a unique 14-character handle. +// Format: 's' + 13-digit zero-padded base36(uid). Same uid always yields the same id; +// different uids never collide because the body is a fixed-width encoding of the integer. func DefaultSparkID(userID int64) string { - s := "s" + strings.ToLower(strconv36(userID)) - for len(s) < SparkIDMin { - s += "0" + if userID < 0 { + userID = -userID } - if len(s) > SparkIDMax { - s = s[:SparkIDMax] + body := strconv36(userID) + var b [1 + sparkIDBodyLen]byte + b[0] = sparkIDPrefix + pad := sparkIDBodyLen - len(body) + for i := 1; i <= pad; i++ { + b[i] = '0' } - return s + copy(b[1+pad:], body) + return string(b[:]) } func strconv36(n int64) string { - if n < 0 { - n = -n - } const digits = "0123456789abcdefghijklmnopqrstuvwxyz" if n == 0 { return "0" diff --git a/internal/module/user/entity/spark_id_test.go b/internal/module/user/entity/spark_id_test.go index 597a810..3b87d58 100644 --- a/internal/module/user/entity/spark_id_test.go +++ b/internal/module/user/entity/spark_id_test.go @@ -1,14 +1,37 @@ package entity -import "testing" +import ( + "math" + "testing" +) func TestDefaultSparkID(t *testing.T) { a := DefaultSparkID(100) b := DefaultSparkID(101) - if !ValidSparkID(a) || !ValidSparkID(b) || a == b || a[0] != 's' { + if a == b || a[0] != 's' || !ValidSparkID(a) || !ValidSparkID(b) { t.Fatalf("%s %s", a, b) } - if !ValidSparkID("alice_01") || ValidSparkID("1abcde") || ValidSparkID("ab") { - t.Fatal("spark_id regex") + if len(a) != 1+sparkIDBodyLen || len(a) > SparkIDMax || len(a) < SparkIDMin { + t.Fatalf("len %s", a) + } + if DefaultSparkID(100) != a { + t.Fatal("not deterministic") + } + if DefaultSparkID(math.MaxInt64) == a || !ValidSparkID(DefaultSparkID(math.MaxInt64)) { + t.Fatal("max int64") + } + seen := map[string]int64{} + for id := int64(0); id < 4000; id++ { + s := DefaultSparkID(id) + if !ValidSparkID(s) { + t.Fatalf("invalid %d %s", id, s) + } + if other, ok := seen[s]; ok { + t.Fatalf("collision %d %d %s", other, id, s) + } + seen[s] = id + } + if ValidSparkID("1abcdef") || ValidSparkID("abcdefg") || ValidSparkID("abcdefghijklmnopq") { + t.Fatal("spark_id rules") } } diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go index c74428b..0ea6c5f 100644 --- a/internal/module/user/logic/login_test.go +++ b/internal/module/user/logic/login_test.go @@ -144,6 +144,12 @@ func TestSnowflakeIDsIncrease(t *testing.T) { if b.ID <= a.ID { t.Fatalf("ids %d %d", a.ID, b.ID) } + if a.SparkID == "" || a.SparkID == b.SparkID || !entity.ValidSparkID(a.SparkID) || !entity.ValidSparkID(b.SparkID) { + t.Fatalf("spark_id %s %s", a.SparkID, b.SparkID) + } + if a.SparkID != entity.DefaultSparkID(a.ID) || b.SparkID != entity.DefaultSparkID(b.ID) { + t.Fatalf("derived %s %s", a.SparkID, b.SparkID) + } } func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) { diff --git a/internal/module/user/logic/profile.go b/internal/module/user/logic/profile.go index 990dfc1..c080057 100644 --- a/internal/module/user/logic/profile.go +++ b/internal/module/user/logic/profile.go @@ -74,12 +74,12 @@ func (s *Service) GetByQRCode(ctx context.Context, qrcode string) (*entity.User, } type UpdateSparkIDInput struct { - SparkID string `json:"spark_id" validate:"required,min=6,max=32" label:"field.spark_id" errmsg:"required:validate.user.spark_id_required;min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + SparkID string `json:"spark_id" validate:"required,min=8,max=16" label:"field.spark_id" errmsg:"required:validate.user.spark_id_required;min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` } // UpdateProfileInput is a partial profile PATCH. Nil fields are left unchanged. type UpdateProfileInput struct { - SparkID *string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + SparkID *string `json:"spark_id" validate:"omitempty,min=8,max=16" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` Nickname *string `json:"nickname" validate:"omitempty,min=1,max=64" label:"field.nickname" errmsg:"min:validate.user.nickname_invalid;max:validate.user.nickname_invalid"` Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar" errmsg:"max:validate.user.avatar_invalid"` Gender *entity.Gender `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` diff --git a/internal/pkg/i18n/catalog_validate.go b/internal/pkg/i18n/catalog_validate.go index 50ba9f6..39df01a 100644 --- a/internal/pkg/i18n/catalog_validate.go +++ b/internal/pkg/i18n/catalog_validate.go @@ -90,7 +90,7 @@ func init() { ZhCN: "请填写Spark账号", En: "Please enter a Spark ID", ZhTW: "請填寫Spark帳號", }, KeyUserSparkIDInvalid: { - ZhCN: "Spark账号需以字母开头,6-32位字母数字或下划线", En: "Spark ID must start with a letter and be 6-32 letters, digits, or underscores", ZhTW: "Spark帳號需以字母開頭,6-32位字母數字或底線", + ZhCN: "Spark账号需以字母开头,8-16位字母数字或下划线", En: "Spark ID must start with a letter and be 8-16 letters, digits, or underscores", ZhTW: "Spark帳號需以字母開頭,8-16位字母數字或底線", }, }) } diff --git a/internal/pkg/i18n/catalog_validate_friend.go b/internal/pkg/i18n/catalog_validate_friend.go index 94508a0..cd456d8 100644 --- a/internal/pkg/i18n/catalog_validate_friend.go +++ b/internal/pkg/i18n/catalog_validate_friend.go @@ -8,6 +8,9 @@ func init() { KeyFieldQRCode: { ZhCN: "二维码", En: "QR code", ZhTW: "二維碼", }, + KeyFieldFriendQuery: { + ZhCN: "查找内容", En: "Search", ZhTW: "查找內容", + }, KeyFieldReason: { ZhCN: "申请理由", En: "Request message", ZhTW: "申請理由", }, @@ -26,6 +29,9 @@ func init() { KeyFriendTargetRequired: { ZhCN: "请填写邮箱、Spark账号或二维码其中一项", En: "Provide exactly one of email, Spark ID, or QR code", ZhTW: "請填寫郵箱、Spark帳號或二維碼其中一項", }, + KeyFriendQueryRequired: { + ZhCN: "请填写查找内容", En: "Please enter a search value", ZhTW: "請填寫查找內容", + }, KeyFriendQRInvalid: { ZhCN: "二维码无效", En: "Invalid QR code", ZhTW: "二維碼無效", }, diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index 39e192a..244c842 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -37,6 +37,7 @@ const ( KeyFieldReason = "field.reason" KeyFieldRemark = "field.remark" KeyFieldRequestID = "field.request_id" + KeyFieldFriendQuery = "field.friend_query" KeyFieldReportReason = "field.report_reason" KeyFieldReportDetail = "field.report_detail" KeyFieldPurpose = "field.purpose" @@ -68,6 +69,7 @@ const ( KeyLoginRefreshRequired = "validate.login.refresh_required" KeyFriendTargetRequired = "validate.friend.target_required" + KeyFriendQueryRequired = "validate.friend.query_required" KeyFriendQRInvalid = "validate.friend.qrcode_invalid" KeyFriendReasonInvalid = "validate.friend.reason_invalid" KeyFriendRemarkInvalid = "validate.friend.remark_invalid" diff --git a/internal/transport/http/admin_auth.go b/internal/transport/http/admin_auth.go new file mode 100644 index 0000000..dd47481 --- /dev/null +++ b/internal/transport/http/admin_auth.go @@ -0,0 +1,41 @@ +package httpx + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + adminlogic "spark-server/internal/module/admin/logic" + adminhandler "spark-server/internal/transport/http/handler/admin" +) + +const adminClaimsKey = "adminClaims" + +func AdminAuth(tokens *adminlogic.TokenService) gin.HandlerFunc { + return func(c *gin.Context) { + if tokens == nil { + adminhandler.Fail(c, http.StatusServiceUnavailable, "admin JWT 未配置") + c.Abort() + return + } + raw, ok := bearerToken(c.GetHeader(headerAuthorization)) + if !ok { + adminhandler.Fail(c, http.StatusUnauthorized, "未登录") + c.Abort() + return + } + claims, err := tokens.Parse(raw) + if err != nil { + adminhandler.Fail(c, http.StatusUnauthorized, "登录已失效") + c.Abort() + return + } + c.Set(adminClaimsKey, claims) + c.Next() + } +} + +func adminPath(path string) bool { + return strings.HasPrefix(path, "/api/admin/") +} diff --git a/internal/transport/http/admin_test.go b/internal/transport/http/admin_test.go new file mode 100644 index 0000000..a474a56 --- /dev/null +++ b/internal/transport/http/admin_test.go @@ -0,0 +1,86 @@ +package httpx + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + adminlogic "spark-server/internal/module/admin/logic" + adminhandler "spark-server/internal/transport/http/handler/admin" +) + +func TestAdminImStatsRequiresJWT(t *testing.T) { + tokens := adminlogic.NewTokenService("test-admin-secret-at-least-32-bytes!!") + r := NewEngine(zap.NewNop(), &EngineDeps{ + Admin: adminlogic.NewService(nil, nil, nil), + AdminTokens: tokens, + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/admin/spark/im/stats", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var env adminhandler.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Success { + t.Fatalf("%+v", env) + } +} + +func TestAdminImStatsWithToken(t *testing.T) { + tokens := adminlogic.NewTokenService("test-admin-secret-at-least-32-bytes!!") + tok, err := tokens.CreateToken(1, "admin", "SUPER_ADMIN", "") + if err != nil { + t.Fatal(err) + } + r := NewEngine(zap.NewNop(), &EngineDeps{ + Admin: adminlogic.NewService(nil, nil, nil), + AdminTokens: tokens, + }) + req := httptest.NewRequest(http.MethodGet, "/api/admin/spark/im/stats", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError && w.Code != http.StatusOK { + // nil db → 500 with admin: mysql not ready message in Fail body + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var env adminhandler.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Success { + t.Fatalf("expected fail without db: %+v", env) + } +} + +func TestAdminPopAlias(t *testing.T) { + tokens := adminlogic.NewTokenService("test-admin-secret-at-least-32-bytes!!") + tok, err := tokens.CreateToken(1, "admin", "SUPER_ADMIN", "") + if err != nil { + t.Fatal(err) + } + r := NewEngine(zap.NewNop(), &EngineDeps{ + Admin: adminlogic.NewService(nil, nil, nil), + AdminTokens: tokens, + }) + req := httptest.NewRequest(http.MethodGet, "/api/admin/pop/api-doc", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d %s", w.Code, w.Body.String()) + } + var env adminhandler.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if !env.Success { + t.Fatalf("%+v", env) + } +} diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index dfc5cad..789a283 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -88,6 +88,10 @@ func publicPath(path string) bool { case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": return true default: + // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. + if adminPath(path) { + return true + } return strings.HasPrefix(path, "/docs/") } } diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 9a78b51..85aa3f0 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -88,7 +88,7 @@ info: | 位置 | 放什么 | 例子 | | --- | --- | --- | | Path | 资源 id | `/v1/friends/{uid}` 的 `uid`;`/v1/groups/{id}` 的群号;`/v1/messages/{id}` 的 MsgId | - | Query | GET 筛选 / 分页 | `GET /v1/friends/lookup?email=`;`limit`;历史 `conversation_id`、`last_msg_id` | + | Query | GET 筛选 / 分页 | `GET /v1/friends/lookup?q=`;`limit`;历史 `conversation_id`、`last_msg_id` | | Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`device_id`;发消息的 `content` | ### 调用顺序 @@ -584,7 +584,7 @@ paths: refresh_expires_in: 2592000 user_id: 1940000000000000000 device_id: 7 - spark_id: s1a2b3c + spark_id: s1a2b3c0 unauthorized: value: code: "40101" @@ -669,7 +669,7 @@ paths: patch: tags: [users] summary: 修改资料 - description: 可改 spark_id、nickname、avatar、gender(0/1/2)、birthday(YYYY-MM-DD)。未出现的字段不改。 + description: 可改 spark_id(8-16 位,字母开头)、nickname、avatar、gender(0/1/2)、birthday(YYYY-MM-DD)。未出现的字段不改。已有超范围 spark_id 可继续使用,改号须符合新规则。 operationId: patchMe requestBody: required: true @@ -706,24 +706,18 @@ paths: /v1/friends/lookup: get: tags: [friends] - summary: 按邮箱 / Spark账号 / 二维码查找用户 + summary: 查找用户 description: | - 查询参数 `email`、`spark_id`、`qrcode`、`uid` 四选一。 + 查询参数 `q`:邮箱则按邮箱查;纯数字按 uid 查;其余先按 spark_id,未命中再按 qrcode。 二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。 operationId: lookupFriend parameters: - in: query - name: email - schema: { type: string, format: email } - - in: query - name: spark_id + name: q + required: true schema: { type: string } - - in: query - name: qrcode - schema: { type: string, example: "-NDveu-9Q" } - - in: query - name: uid - schema: { type: integer, format: int64 } + description: 邮箱 / uid / Spark账号 / 二维码 + example: test02@spark.com responses: "200": description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑) @@ -2139,7 +2133,7 @@ components: format: int64 spark_id: type: string - description: Public handle, unique and user-changeable + description: Public handle, unique and user-changeable. New and updated values are 8-16 characters. RefreshRequest: type: object required: [refresh_token] @@ -2151,9 +2145,9 @@ components: properties: spark_id: type: string - minLength: 6 - maxLength: 32 - pattern: "^[a-zA-Z][a-zA-Z0-9_]{5,31}$" + minLength: 8 + maxLength: 16 + pattern: "^[a-zA-Z][a-zA-Z0-9_]{7,15}$" nickname: type: string minLength: 1 diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go index 32cdd6a..a51b21d 100644 --- a/internal/transport/http/friend_test.go +++ b/internal/transport/http/friend_test.go @@ -130,6 +130,10 @@ func TestFriendApplyAcceptHTTP(t *testing.T) { if got.Code != "0" || !strings.Contains(string(got.Data), `"remark":"work"`) { t.Fatalf("get %+v", got) } + look := authJSON(t, h, &a, http.MethodGet, "/v1/friends/lookup?q=test02@spark.com", "") + if look.Code != "0" || !strings.Contains(string(look.Data), fmt.Sprintf(`"uid":%d`, b.UserID)) { + t.Fatalf("lookup %+v", look) + } list := authJSON(t, h, &b, http.MethodGet, "/v1/friends", "") if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) { t.Fatalf("list %+v", list) diff --git a/internal/transport/http/handler/admin/im_groups.go b/internal/transport/http/handler/admin/im_groups.go new file mode 100644 index 0000000..3370960 --- /dev/null +++ b/internal/transport/http/handler/admin/im_groups.go @@ -0,0 +1,241 @@ +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) + if err != nil { + 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 _, m := range g.Members { + _ = 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)) +} diff --git a/internal/transport/http/handler/admin/response.go b/internal/transport/http/handler/admin/response.go new file mode 100644 index 0000000..3ec8149 --- /dev/null +++ b/internal/transport/http/handler/admin/response.go @@ -0,0 +1,29 @@ +package adminhandler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ApiResponse matches Java com.aliyun.control.dto.ApiResponse for popadmin.js. +type ApiResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data any `json:"data"` +} + +func OK(c *gin.Context, data any) { + c.JSON(http.StatusOK, ApiResponse{Success: true, Message: "ok", Data: data}) +} + +func OKMsg(c *gin.Context, msg string, data any) { + c.JSON(http.StatusOK, ApiResponse{Success: true, Message: msg, Data: data}) +} + +func Fail(c *gin.Context, status int, msg string) { + if status <= 0 { + status = http.StatusBadRequest + } + c.JSON(status, ApiResponse{Success: false, Message: msg, Data: nil}) +} diff --git a/internal/transport/http/handler/admin/users.go b/internal/transport/http/handler/admin/users.go new file mode 100644 index 0000000..b716938 --- /dev/null +++ b/internal/transport/http/handler/admin/users.go @@ -0,0 +1,190 @@ +package adminhandler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "spark-server/internal/pkg/errs" +) + +func (h *Handler) ListUsers(c *gin.Context) { + if !h.ready(c) { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) + list, total, err := h.svc.ListUsers(c.Request.Context(), c.Query("email"), page, size) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{ + "content": list, "totalElements": total, + "page": page, "size": size, "appCode": "spark", + }) +} + +func (h *Handler) UserByEmail(c *gin.Context) { + if !h.ready(c) { + return + } + out, err := h.svc.UserByEmail(c.Request.Context(), c.Query("email")) + if err != nil { + if errors.Is(err, errs.ErrInvalidArgument) { + Fail(c, http.StatusBadRequest, "email 必填") + return + } + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, out) +} + +func (h *Handler) UserByID(c *gin.Context) { + if !h.ready(c) { + return + } + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + u, err := h.svc.UserByID(c.Request.Context(), id) + if err != nil { + Fail(c, http.StatusNotFound, "用户不存在") + return + } + OK(c, u) +} + +func (h *Handler) UserByNickname(c *gin.Context) { + if !h.ready(c) { + return + } + list, err := h.svc.UsersByNickname(c.Request.Context(), c.Query("nickname")) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"items": list, "count": len(list)}) +} + +func (h *Handler) UserByDevice(c *gin.Context) { + if !h.ready(c) { + return + } + list, err := h.svc.UsersByDevice(c.Request.Context(), c.Query("deviceId")) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"items": list, "count": len(list)}) +} + +func (h *Handler) UserByIP(c *gin.Context) { + if !h.ready(c) { + return + } + ip := c.Query("ip") + list, err := h.svc.UsersByIP(c.Request.Context(), ip) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"items": list, "count": len(list), "ip": ip}) +} + +type emailActionReq struct { + Email string `json:"email"` + Reason string `json:"reason"` + Value *bool `json:"value"` + Banned *bool `json:"banned"` + Nickname string `json:"nickname"` +} + +func (h *Handler) BanByEmail(c *gin.Context) { + if !h.ready(c) { + return + } + var req emailActionReq + _ = c.ShouldBindJSON(&req) + email := firstNonEmpty(req.Email, c.Query("email")) + banned := true + if req.Banned != nil { + banned = *req.Banned + } else if req.Value != nil { + banned = *req.Value + } + if err := h.svc.BanByEmail(c.Request.Context(), email, banned); err != nil { + if errors.Is(err, errs.ErrNotFound) { + Fail(c, http.StatusNotFound, "用户不存在") + return + } + if errors.Is(err, errs.ErrInvalidArgument) { + Fail(c, http.StatusBadRequest, "email 必填") + return + } + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OKMsg(c, "已更新封禁状态", gin.H{"email": email, "banned": banned}) +} + +func (h *Handler) ResetNicknameByEmail(c *gin.Context) { + if !h.ready(c) { + return + } + var req emailActionReq + _ = c.ShouldBindJSON(&req) + email := firstNonEmpty(req.Email, c.Query("email")) + nick := req.Nickname + if err := h.svc.ResetNicknameByEmail(c.Request.Context(), email, nick); err != nil { + if errors.Is(err, errs.ErrNotFound) { + Fail(c, http.StatusNotFound, "用户不存在") + return + } + Fail(c, http.StatusBadRequest, err.Error()) + return + } + if nick == "" { + nick = "用户" + } + OKMsg(c, "已重置昵称", gin.H{"email": email, "nickname": nick}) +} + +func (h *Handler) ResetAvatarByEmail(c *gin.Context) { + if !h.ready(c) { + return + } + var req emailActionReq + _ = c.ShouldBindJSON(&req) + email := firstNonEmpty(req.Email, c.Query("email")) + if err := h.svc.ResetAvatarByEmail(c.Request.Context(), email); err != nil { + if errors.Is(err, errs.ErrNotFound) { + Fail(c, http.StatusNotFound, "用户不存在") + return + } + Fail(c, http.StatusBadRequest, err.Error()) + return + } + OKMsg(c, "已重置头像", gin.H{"email": email}) +} + +func (h *Handler) ResetPasswordByEmail(c *gin.Context) { + Fail(c, http.StatusBadRequest, "Spark V3 使用邮箱验证码登录,无密码可重置") +} + +func (h *Handler) WhitelistByEmail(c *gin.Context) { + OKMsg(c, "V3 暂无账号白名单字段,已忽略", gin.H{"note": "请在 Java ops lists 维护"}) +} + +func (h *Handler) MarkAbnormalByEmail(c *gin.Context) { + OKMsg(c, "V3 暂无异常标记字段,已忽略", gin.H{"note": "可用封禁代替"}) +} + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return strings.TrimSpace(a) + } + return strings.TrimSpace(b) +} diff --git a/internal/transport/http/handler/friend/handler.go b/internal/transport/http/handler/friend/handler.go index ed51405..790c281 100644 --- a/internal/transport/http/handler/friend/handler.go +++ b/internal/transport/http/handler/friend/handler.go @@ -25,19 +25,7 @@ func (h *Handler) Lookup(c *gin.Context) { if !ok { return } - in := friendlogic.LookupInput{ - Email: c.Query("email"), - SparkID: c.Query("spark_id"), - QRCode: c.Query("qrcode"), - } - if raw := c.Query("uid"); raw != "" { - id, err := strconv.ParseInt(raw, 10, 64) - if err != nil || id <= 0 { - respond.JSONError(c, errs.ErrInvalidArgument) - return - } - in.UID = id - } + in := friendlogic.LookupInput{Q: c.Query("q")} view, err := h.friends.Lookup(c.Request.Context(), uid, in) if err != nil { respond.JSONError(c, err) diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 8f8f509..cc5bbd6 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -25,6 +25,7 @@ import ( func registerRoutes(r *gin.Engine, deps *EngineDeps) { registerDocs(r) registerHealth(r, deps.Health) + registerAdminRoutes(r, deps) v1 := r.Group("/v1") registerMetaRoutes(v1) registerAuthRoutes(v1, deps) diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go new file mode 100644 index 0000000..a4a9928 --- /dev/null +++ b/internal/transport/http/router_admin.go @@ -0,0 +1,65 @@ +package httpx + +import ( + "github.com/gin-gonic/gin" + + adminhandler "spark-server/internal/transport/http/handler/admin" +) + +func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { + if deps == nil || deps.Admin == nil || deps.AdminTokens == nil { + return + } + h := adminhandler.New(deps.Admin) + auth := AdminAuth(deps.AdminTokens) + + // Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务) + ug := r.Group("/api/admin") + ug.Use(auth) + { + ug.GET("/api-doc", h.ApiDoc) + ug.GET("/users", h.ListUsers) + ug.GET("/users/by-email", h.UserByEmail) + ug.GET("/users/by-nickname", h.UserByNickname) + ug.GET("/users/by-device", h.UserByDevice) + ug.GET("/users/by-ip", h.UserByIP) + ug.GET("/users/:id", h.UserByID) + ug.POST("/users/by-email/ban", h.BanByEmail) + ug.POST("/users/by-email/whitelist", h.WhitelistByEmail) + ug.POST("/users/by-email/mark-abnormal", h.MarkAbnormalByEmail) + ug.POST("/users/by-email/reset-nickname", h.ResetNicknameByEmail) + ug.POST("/users/by-email/reset-avatar", h.ResetAvatarByEmail) + ug.POST("/users/by-email/reset-password", h.ResetPasswordByEmail) + } + + for _, prefix := range []string{"/api/admin/spark", "/api/admin/pop"} { + g := r.Group(prefix) + g.Use(auth) + registerSparkAdmin(g, h) + } +} + +func registerSparkAdmin(g *gin.RouterGroup, h *adminhandler.Handler) { + g.GET("/api-doc", h.ApiDoc) + g.GET("/ops-catalog", h.OpsCatalog) + + g.GET("/im/stats", h.ImStats) + g.GET("/im/messages", h.ImMessages) + g.GET("/im/conversations", h.ImConversations) + + g.GET("/groups", h.ListGroups) + g.GET("/groups/:groupId", h.GroupDetail) + g.POST("/groups", h.UpsertGroup) + g.DELETE("/groups/:groupId", h.DeleteGroup) + g.GET("/groups/:groupId/export-zip", h.ExportGroupZip) + + g.GET("/users/by-nickname", h.UserByNickname) + g.GET("/users/by-device", h.UserByDevice) + g.GET("/users/by-ip", h.UserByIP) + + g.GET("/rankings/same-ip", h.SameIPRank) + g.GET("/rankings/:type", h.Ranking) + + g.GET("/complaints", h.ListComplaints) + g.POST("/complaints", h.AddComplaint) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 1e36732..e9f2bd1 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + adminlogic "spark-server/internal/module/admin/logic" authlogic "spark-server/internal/module/auth/logic" favlogic "spark-server/internal/module/favorite/logic" friendlogic "spark-server/internal/module/friend/logic" @@ -39,18 +40,20 @@ type Health interface { // EngineDeps wires Application services into Gin. Auth nil disables Bearer checks (tests). type EngineDeps struct { - Auth authlogic.Service - Login *authlogic.LoginService - Users *userlogic.Service - Friends *friendlogic.Service - Groups *grouplogic.Service - Files *uploadlogic.Service - Favorites *favlogic.Service - Moments *momentlogic.Service - Messages *msglogic.Service - Sync *synclogic.Service - Health Health - AuthLimiter *ratelimit.Limiter + Auth authlogic.Service + Login *authlogic.LoginService + Users *userlogic.Service + Friends *friendlogic.Service + Groups *grouplogic.Service + Files *uploadlogic.Service + Favorites *favlogic.Service + Moments *momentlogic.Service + Messages *msglogic.Service + Sync *synclogic.Service + Admin *adminlogic.Service + AdminTokens *adminlogic.TokenService + Health Health + AuthLimiter *ratelimit.Limiter } func New(log *zap.Logger, addr string, deps *EngineDeps) *Server {