feat: 1、podadmin 2、user search 3、spark_id optimize

This commit is contained in:
zhr 2026-09-21 15:24:46 +08:00
parent 1f9f1949ad
commit 0e84699df7
46 changed files with 2489 additions and 77 deletions

View File

@ -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) |

View File

@ -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/` |

View File

@ -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

View File

@ -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`。

View File

@ -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;
}

View File

@ -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

View File

@ -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")

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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"`

View File

@ -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 {

View File

@ -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
}

View File

@ -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"

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -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"`

View File

@ -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位字母數字或底線",
},
})
}

View File

@ -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: "二維碼無效",
},

View File

@ -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"

View File

@ -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/")
}

View File

@ -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)
}
}

View File

@ -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/")
}
}

View File

@ -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

View File

@ -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)

View File

@ -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))
}

View File

@ -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})
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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 {