feat: 1、podadmin 2、user search 3、spark_id optimize
This commit is contained in:
parent
1f9f1949ad
commit
0e84699df7
@ -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` 错误码) |
|
| 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` |
|
| 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` |
|
| 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 |
|
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
||||||
| conversations | `/v1/conversations` | list, mute |
|
| conversations | `/v1/conversations` | list, mute |
|
||||||
| messages | `/v1/messages` | history (sync still prefers TCP) |
|
| messages | `/v1/messages` | history (sync still prefers TCP) |
|
||||||
|
|||||||
@ -122,7 +122,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
|
|||||||
| 枚举 | `GET /v1/meta/enums` | 无需登录 |
|
| 枚举 | `GET /v1/meta/enums` | 无需登录 |
|
||||||
| 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 |
|
| 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 |
|
||||||
| 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) |
|
| 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) |
|
||||||
| 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 |
|
| 好友 | `/v1/friends` | 申请、同意、查找(`GET /lookup?q=`)、备注、置顶、拉黑、举报 |
|
||||||
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
|
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
|
||||||
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
|
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
|
||||||
| 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` |
|
| 收藏 | `/v1/favorites` | 收藏;媒体会 Copy 到 `perm/fav/` |
|
||||||
|
|||||||
@ -57,6 +57,10 @@ jwt:
|
|||||||
denylist_prefix: "denylist:"
|
denylist_prefix: "denylist:"
|
||||||
refresh_prefix: "refresh:"
|
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:
|
taskpool:
|
||||||
size: 256
|
size: 256
|
||||||
queue: 1024
|
queue: 1024
|
||||||
|
|||||||
@ -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`.
|
`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`。
|
||||||
|
|||||||
60
deploy/nginx/host/popadmin-spark-admin-locations.conf
Normal file
60
deploy/nginx/host/popadmin-spark-admin-locations.conf
Normal 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;
|
||||||
|
}
|
||||||
@ -20,6 +20,8 @@ import (
|
|||||||
"spark-server/internal/infrastructure/mysql"
|
"spark-server/internal/infrastructure/mysql"
|
||||||
"spark-server/internal/infrastructure/objectstore"
|
"spark-server/internal/infrastructure/objectstore"
|
||||||
redisx "spark-server/internal/infrastructure/redis"
|
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"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
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)
|
favorites := wireFavorites(idgen, db, objects, chat)
|
||||||
moments := wireMoments(idgen, db, objects, friends, users)
|
moments := wireMoments(idgen, db, objects, friends, users)
|
||||||
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...)
|
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{
|
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||||
Addr: cfg.Server.TCP.Addr,
|
Addr: cfg.Server.TCP.Addr,
|
||||||
Multicore: cfg.Server.TCP.Multicore,
|
Multicore: cfg.Server.TCP.Multicore,
|
||||||
@ -203,12 +206,23 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
|||||||
Moments: moments,
|
Moments: moments,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Sync: syncSvc,
|
Sync: syncSvc,
|
||||||
|
Admin: adminSvc,
|
||||||
|
AdminTokens: adminTokens,
|
||||||
Health: probe,
|
Health: probe,
|
||||||
AuthLimiter: authLimit,
|
AuthLimiter: authLimit,
|
||||||
})
|
})
|
||||||
return app, nil
|
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) {
|
func loadTLS(t TLSConfig) (*tls.Config, error) {
|
||||||
if t.CertFile == "" || t.KeyFile == "" {
|
if t.CertFile == "" || t.KeyFile == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type Config struct {
|
|||||||
MySQL MySQLConfig `yaml:"mysql"`
|
MySQL MySQLConfig `yaml:"mysql"`
|
||||||
Redis RedisConfig `yaml:"redis"`
|
Redis RedisConfig `yaml:"redis"`
|
||||||
JWT JWTConfig `yaml:"jwt"`
|
JWT JWTConfig `yaml:"jwt"`
|
||||||
|
Admin AdminConfig `yaml:"admin"`
|
||||||
TaskPool TaskPoolConfig `yaml:"taskpool"`
|
TaskPool TaskPoolConfig `yaml:"taskpool"`
|
||||||
Deliver DeliverConfig `yaml:"deliver"`
|
Deliver DeliverConfig `yaml:"deliver"`
|
||||||
Session SessionConfig `yaml:"session"`
|
Session SessionConfig `yaml:"session"`
|
||||||
@ -26,6 +27,11 @@ type Config struct {
|
|||||||
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
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 {
|
type LogConfig struct {
|
||||||
Level string `yaml:"level"`
|
Level string `yaml:"level"`
|
||||||
Encoding string `yaml:"encoding"`
|
Encoding string `yaml:"encoding"`
|
||||||
@ -185,6 +191,12 @@ func applyDefaults(cfg *Config) {
|
|||||||
if v := os.Getenv("SPARK_JWT_SECRET"); v != "" {
|
if v := os.Getenv("SPARK_JWT_SECRET"); v != "" {
|
||||||
cfg.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 != "" {
|
if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" {
|
||||||
cfg.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 {
|
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")
|
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 ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" {
|
||||||
if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" {
|
if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" {
|
||||||
return fmt.Errorf("prod: object_storage access_key and secret_key are required")
|
return fmt.Errorf("prod: object_storage access_key and secret_key are required")
|
||||||
|
|||||||
@ -32,6 +32,10 @@ func TestParseEnvAndProductionValidate(t *testing.T) {
|
|||||||
t.Fatal("prod default jwt")
|
t.Fatal("prod default jwt")
|
||||||
}
|
}
|
||||||
prod.JWT.Secret = "long-enough-production-secret"
|
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 {
|
if err := prod.normalize(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
86
internal/module/admin/entity/view.go
Normal file
86
internal/module/admin/entity/view.go
Normal 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"`
|
||||||
|
}
|
||||||
105
internal/module/admin/logic/complaints.go
Normal file
105
internal/module/admin/logic/complaints.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/module/admin/logic/groups.go
Normal file
77
internal/module/admin/logic/groups.go
Normal 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)
|
||||||
|
}
|
||||||
86
internal/module/admin/logic/im.go
Normal file
86
internal/module/admin/logic/im.go
Normal 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
|
||||||
|
}
|
||||||
83
internal/module/admin/logic/jwt.go
Normal file
83
internal/module/admin/logic/jwt.go
Normal 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)
|
||||||
|
}
|
||||||
31
internal/module/admin/logic/jwt_test.go
Normal file
31
internal/module/admin/logic/jwt_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
159
internal/module/admin/logic/map.go
Normal file
159
internal/module/admin/logic/map.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/module/admin/logic/service.go
Normal file
30
internal/module/admin/logic/service.go
Normal 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
|
||||||
|
}
|
||||||
15
internal/module/admin/logic/types.go
Normal file
15
internal/module/admin/logic/types.go
Normal 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
|
||||||
|
)
|
||||||
171
internal/module/admin/logic/users.go
Normal file
171
internal/module/admin/logic/users.go
Normal 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
|
||||||
|
}
|
||||||
58
internal/module/admin/model/complaints.go
Normal file
58
internal/module/admin/model/complaints.go
Normal 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)
|
||||||
|
}
|
||||||
124
internal/module/admin/model/groups.go
Normal file
124
internal/module/admin/model/groups.go
Normal 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
|
||||||
|
}
|
||||||
143
internal/module/admin/model/im.go
Normal file
143
internal/module/admin/model/im.go
Normal 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
|
||||||
|
}
|
||||||
41
internal/module/admin/model/repo.go
Normal file
41
internal/module/admin/model/repo.go
Normal 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
|
||||||
|
}
|
||||||
73
internal/module/admin/model/row.go
Normal file
73
internal/module/admin/model/row.go
Normal 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
|
||||||
|
}
|
||||||
266
internal/module/admin/model/users.go
Normal file
266
internal/module/admin/model/users.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ import (
|
|||||||
type ApplyInput struct {
|
type ApplyInput struct {
|
||||||
UID int64 `json:"uid" validate:"omitempty,gt=0" label:"field.uid"`
|
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"`
|
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"`
|
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"`
|
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"`
|
Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"`
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package logic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
@ -207,7 +208,7 @@ func TestRemarkReportLookup(t *testing.T) {
|
|||||||
svc, users, _ := testFriends(t)
|
svc, users, _ := testFriends(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
a, b := twoUsers(t, users)
|
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 {
|
if err != nil || look.UID != b.ID || look.Relation != entity.RelationNone {
|
||||||
t.Fatalf("lookup %+v %v", look, err)
|
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) {
|
func TestSystemGreeterWritesMessage(t *testing.T) {
|
||||||
ids, err := id.New(1)
|
ids, err := id.New(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -3,26 +3,32 @@ package logic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"spark-server/internal/module/friend/entity"
|
"spark-server/internal/module/friend/entity"
|
||||||
|
userentity "spark-server/internal/module/user/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"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 {
|
type LookupInput struct {
|
||||||
UID int64
|
Q string `json:"q" validate:"required,max=255" label:"field.friend_query" errmsg:"required:validate.friend.query_required"`
|
||||||
Email string
|
|
||||||
SparkID string
|
|
||||||
QRCode string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*FriendView, error) {
|
func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*FriendView, error) {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
target, _, err := s.resolveTarget(ctx, &ApplyInput{
|
target, err := s.resolveLookupQuery(ctx, strings.TrimSpace(in.Q))
|
||||||
UID: in.UID, Email: in.Email, SparkID: in.SparkID, QRCode: in.QRCode,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -56,3 +62,37 @@ func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*Fr
|
|||||||
}
|
}
|
||||||
return view, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,37 +2,43 @@ package entity
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SparkIDMin = 6
|
SparkIDMin = 8
|
||||||
SparkIDMax = 32
|
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 {
|
func ValidSparkID(id string) bool {
|
||||||
n := len(id)
|
n := len(id)
|
||||||
return n >= SparkIDMin && n <= SparkIDMax && sparkIDRe.MatchString(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 {
|
func DefaultSparkID(userID int64) string {
|
||||||
s := "s" + strings.ToLower(strconv36(userID))
|
if userID < 0 {
|
||||||
for len(s) < SparkIDMin {
|
userID = -userID
|
||||||
s += "0"
|
|
||||||
}
|
}
|
||||||
if len(s) > SparkIDMax {
|
body := strconv36(userID)
|
||||||
s = s[:SparkIDMax]
|
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 {
|
func strconv36(n int64) string {
|
||||||
if n < 0 {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@ -1,14 +1,37 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDefaultSparkID(t *testing.T) {
|
func TestDefaultSparkID(t *testing.T) {
|
||||||
a := DefaultSparkID(100)
|
a := DefaultSparkID(100)
|
||||||
b := DefaultSparkID(101)
|
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)
|
t.Fatalf("%s %s", a, b)
|
||||||
}
|
}
|
||||||
if !ValidSparkID("alice_01") || ValidSparkID("1abcde") || ValidSparkID("ab") {
|
if len(a) != 1+sparkIDBodyLen || len(a) > SparkIDMax || len(a) < SparkIDMin {
|
||||||
t.Fatal("spark_id regex")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,6 +144,12 @@ func TestSnowflakeIDsIncrease(t *testing.T) {
|
|||||||
if b.ID <= a.ID {
|
if b.ID <= a.ID {
|
||||||
t.Fatalf("ids %d %d", a.ID, b.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) {
|
func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) {
|
||||||
|
|||||||
@ -74,12 +74,12 @@ func (s *Service) GetByQRCode(ctx context.Context, qrcode string) (*entity.User,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateSparkIDInput struct {
|
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.
|
// UpdateProfileInput is a partial profile PATCH. Nil fields are left unchanged.
|
||||||
type UpdateProfileInput struct {
|
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"`
|
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"`
|
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"`
|
Gender *entity.Gender `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"`
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func init() {
|
|||||||
ZhCN: "请填写Spark账号", En: "Please enter a Spark ID", ZhTW: "請填寫Spark帳號",
|
ZhCN: "请填写Spark账号", En: "Please enter a Spark ID", ZhTW: "請填寫Spark帳號",
|
||||||
},
|
},
|
||||||
KeyUserSparkIDInvalid: {
|
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位字母數字或底線",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,9 @@ func init() {
|
|||||||
KeyFieldQRCode: {
|
KeyFieldQRCode: {
|
||||||
ZhCN: "二维码", En: "QR code", ZhTW: "二維碼",
|
ZhCN: "二维码", En: "QR code", ZhTW: "二維碼",
|
||||||
},
|
},
|
||||||
|
KeyFieldFriendQuery: {
|
||||||
|
ZhCN: "查找内容", En: "Search", ZhTW: "查找內容",
|
||||||
|
},
|
||||||
KeyFieldReason: {
|
KeyFieldReason: {
|
||||||
ZhCN: "申请理由", En: "Request message", ZhTW: "申請理由",
|
ZhCN: "申请理由", En: "Request message", ZhTW: "申請理由",
|
||||||
},
|
},
|
||||||
@ -26,6 +29,9 @@ func init() {
|
|||||||
KeyFriendTargetRequired: {
|
KeyFriendTargetRequired: {
|
||||||
ZhCN: "请填写邮箱、Spark账号或二维码其中一项", En: "Provide exactly one of email, Spark ID, or QR code", ZhTW: "請填寫郵箱、Spark帳號或二維碼其中一項",
|
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: {
|
KeyFriendQRInvalid: {
|
||||||
ZhCN: "二维码无效", En: "Invalid QR code", ZhTW: "二維碼無效",
|
ZhCN: "二维码无效", En: "Invalid QR code", ZhTW: "二維碼無效",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const (
|
|||||||
KeyFieldReason = "field.reason"
|
KeyFieldReason = "field.reason"
|
||||||
KeyFieldRemark = "field.remark"
|
KeyFieldRemark = "field.remark"
|
||||||
KeyFieldRequestID = "field.request_id"
|
KeyFieldRequestID = "field.request_id"
|
||||||
|
KeyFieldFriendQuery = "field.friend_query"
|
||||||
KeyFieldReportReason = "field.report_reason"
|
KeyFieldReportReason = "field.report_reason"
|
||||||
KeyFieldReportDetail = "field.report_detail"
|
KeyFieldReportDetail = "field.report_detail"
|
||||||
KeyFieldPurpose = "field.purpose"
|
KeyFieldPurpose = "field.purpose"
|
||||||
@ -68,6 +69,7 @@ const (
|
|||||||
KeyLoginRefreshRequired = "validate.login.refresh_required"
|
KeyLoginRefreshRequired = "validate.login.refresh_required"
|
||||||
|
|
||||||
KeyFriendTargetRequired = "validate.friend.target_required"
|
KeyFriendTargetRequired = "validate.friend.target_required"
|
||||||
|
KeyFriendQueryRequired = "validate.friend.query_required"
|
||||||
KeyFriendQRInvalid = "validate.friend.qrcode_invalid"
|
KeyFriendQRInvalid = "validate.friend.qrcode_invalid"
|
||||||
KeyFriendReasonInvalid = "validate.friend.reason_invalid"
|
KeyFriendReasonInvalid = "validate.friend.reason_invalid"
|
||||||
KeyFriendRemarkInvalid = "validate.friend.remark_invalid"
|
KeyFriendRemarkInvalid = "validate.friend.remark_invalid"
|
||||||
|
|||||||
41
internal/transport/http/admin_auth.go
Normal file
41
internal/transport/http/admin_auth.go
Normal 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/")
|
||||||
|
}
|
||||||
86
internal/transport/http/admin_test.go
Normal file
86
internal/transport/http/admin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -88,6 +88,10 @@ func publicPath(path string) bool {
|
|||||||
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
|
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||||
|
if adminPath(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return strings.HasPrefix(path, "/docs/")
|
return strings.HasPrefix(path, "/docs/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,7 +88,7 @@ info:
|
|||||||
| 位置 | 放什么 | 例子 |
|
| 位置 | 放什么 | 例子 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Path | 资源 id | `/v1/friends/{uid}` 的 `uid`;`/v1/groups/{id}` 的群号;`/v1/messages/{id}` 的 MsgId |
|
| 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` |
|
| Body | POST/PATCH 的业务字段 | 登录的 `email`/`verify_code`/`device_id`;发消息的 `content` |
|
||||||
|
|
||||||
### 调用顺序
|
### 调用顺序
|
||||||
@ -584,7 +584,7 @@ paths:
|
|||||||
refresh_expires_in: 2592000
|
refresh_expires_in: 2592000
|
||||||
user_id: 1940000000000000000
|
user_id: 1940000000000000000
|
||||||
device_id: 7
|
device_id: 7
|
||||||
spark_id: s1a2b3c
|
spark_id: s1a2b3c0
|
||||||
unauthorized:
|
unauthorized:
|
||||||
value:
|
value:
|
||||||
code: "40101"
|
code: "40101"
|
||||||
@ -669,7 +669,7 @@ paths:
|
|||||||
patch:
|
patch:
|
||||||
tags: [users]
|
tags: [users]
|
||||||
summary: 修改资料
|
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
|
operationId: patchMe
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@ -706,24 +706,18 @@ paths:
|
|||||||
/v1/friends/lookup:
|
/v1/friends/lookup:
|
||||||
get:
|
get:
|
||||||
tags: [friends]
|
tags: [friends]
|
||||||
summary: 按邮箱 / Spark账号 / 二维码查找用户
|
summary: 查找用户
|
||||||
description: |
|
description: |
|
||||||
查询参数 `email`、`spark_id`、`qrcode`、`uid` 四选一。
|
查询参数 `q`:邮箱则按邮箱查;纯数字按 uid 查;其余先按 spark_id,未命中再按 qrcode。
|
||||||
二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。
|
二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。
|
||||||
operationId: lookupFriend
|
operationId: lookupFriend
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: email
|
name: q
|
||||||
schema: { type: string, format: email }
|
required: true
|
||||||
- in: query
|
|
||||||
name: spark_id
|
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
- in: query
|
description: 邮箱 / uid / Spark账号 / 二维码
|
||||||
name: qrcode
|
example: test02@spark.com
|
||||||
schema: { type: string, example: "-NDveu-9Q" }
|
|
||||||
- in: query
|
|
||||||
name: uid
|
|
||||||
schema: { type: integer, format: int64 }
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑)
|
description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑)
|
||||||
@ -2139,7 +2133,7 @@ components:
|
|||||||
format: int64
|
format: int64
|
||||||
spark_id:
|
spark_id:
|
||||||
type: string
|
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:
|
RefreshRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [refresh_token]
|
required: [refresh_token]
|
||||||
@ -2151,9 +2145,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
spark_id:
|
spark_id:
|
||||||
type: string
|
type: string
|
||||||
minLength: 6
|
minLength: 8
|
||||||
maxLength: 32
|
maxLength: 16
|
||||||
pattern: "^[a-zA-Z][a-zA-Z0-9_]{5,31}$"
|
pattern: "^[a-zA-Z][a-zA-Z0-9_]{7,15}$"
|
||||||
nickname:
|
nickname:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
|||||||
@ -130,6 +130,10 @@ func TestFriendApplyAcceptHTTP(t *testing.T) {
|
|||||||
if got.Code != "0" || !strings.Contains(string(got.Data), `"remark":"work"`) {
|
if got.Code != "0" || !strings.Contains(string(got.Data), `"remark":"work"`) {
|
||||||
t.Fatalf("get %+v", got)
|
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", "")
|
list := authJSON(t, h, &b, http.MethodGet, "/v1/friends", "")
|
||||||
if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) {
|
if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) {
|
||||||
t.Fatalf("list %+v", list)
|
t.Fatalf("list %+v", list)
|
||||||
|
|||||||
241
internal/transport/http/handler/admin/im_groups.go
Normal file
241
internal/transport/http/handler/admin/im_groups.go
Normal 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))
|
||||||
|
}
|
||||||
29
internal/transport/http/handler/admin/response.go
Normal file
29
internal/transport/http/handler/admin/response.go
Normal 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})
|
||||||
|
}
|
||||||
190
internal/transport/http/handler/admin/users.go
Normal file
190
internal/transport/http/handler/admin/users.go
Normal 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)
|
||||||
|
}
|
||||||
@ -25,19 +25,7 @@ func (h *Handler) Lookup(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in := friendlogic.LookupInput{
|
in := friendlogic.LookupInput{Q: c.Query("q")}
|
||||||
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
|
|
||||||
}
|
|
||||||
view, err := h.friends.Lookup(c.Request.Context(), uid, in)
|
view, err := h.friends.Lookup(c.Request.Context(), uid, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import (
|
|||||||
func registerRoutes(r *gin.Engine, deps *EngineDeps) {
|
func registerRoutes(r *gin.Engine, deps *EngineDeps) {
|
||||||
registerDocs(r)
|
registerDocs(r)
|
||||||
registerHealth(r, deps.Health)
|
registerHealth(r, deps.Health)
|
||||||
|
registerAdminRoutes(r, deps)
|
||||||
v1 := r.Group("/v1")
|
v1 := r.Group("/v1")
|
||||||
registerMetaRoutes(v1)
|
registerMetaRoutes(v1)
|
||||||
registerAuthRoutes(v1, deps)
|
registerAuthRoutes(v1, deps)
|
||||||
|
|||||||
65
internal/transport/http/router_admin.go
Normal file
65
internal/transport/http/router_admin.go
Normal 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)
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
adminlogic "spark-server/internal/module/admin/logic"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
favlogic "spark-server/internal/module/favorite/logic"
|
favlogic "spark-server/internal/module/favorite/logic"
|
||||||
friendlogic "spark-server/internal/module/friend/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).
|
// EngineDeps wires Application services into Gin. Auth nil disables Bearer checks (tests).
|
||||||
type EngineDeps struct {
|
type EngineDeps struct {
|
||||||
Auth authlogic.Service
|
Auth authlogic.Service
|
||||||
Login *authlogic.LoginService
|
Login *authlogic.LoginService
|
||||||
Users *userlogic.Service
|
Users *userlogic.Service
|
||||||
Friends *friendlogic.Service
|
Friends *friendlogic.Service
|
||||||
Groups *grouplogic.Service
|
Groups *grouplogic.Service
|
||||||
Files *uploadlogic.Service
|
Files *uploadlogic.Service
|
||||||
Favorites *favlogic.Service
|
Favorites *favlogic.Service
|
||||||
Moments *momentlogic.Service
|
Moments *momentlogic.Service
|
||||||
Messages *msglogic.Service
|
Messages *msglogic.Service
|
||||||
Sync *synclogic.Service
|
Sync *synclogic.Service
|
||||||
Health Health
|
Admin *adminlogic.Service
|
||||||
AuthLimiter *ratelimit.Limiter
|
AdminTokens *adminlogic.TokenService
|
||||||
|
Health Health
|
||||||
|
AuthLimiter *ratelimit.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(log *zap.Logger, addr string, deps *EngineDeps) *Server {
|
func New(log *zap.Logger, addr string, deps *EngineDeps) *Server {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user