diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index 5206660..8cddbe3 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -104,7 +104,7 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del - New goroutine I/O does not belong in `OnTraffic`. - HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context. - Closed type/status: entity typed enum + `Valid()` + OpenAPI named `$ref` + validator `oneof` on the same values ([enums.md](enums.md)). -- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`). +- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`). Logout is `POST /v1/auth/logout` (current device). Account cancellation is `DELETE /v1/users/me` (archive to `user_delete`, purge friends/moments/related). - SQL tables and GORM models carry COMMENT on the table and every column. - After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy. - Run `make lint` (funlen 100). diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index 92038a6..b9f45ef 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -80,7 +80,7 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans |-------|--------|----------| | system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) | | auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | -| users | `/v1/users` | `GET/PATCH /v1/users/me` | +| users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` | | friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | | conversations | `/v1/conversations` | list, mute | diff --git a/.gitignore b/.gitignore index cdf32ed..c364520 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ vendor/ coverage.out .env certs/*.key +server diff --git a/README.md b/README.md index 0478681..5e2e564 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,8 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 | 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 | | 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 | | 枚举 | `GET /v1/meta/enums` | 无需登录 | -| 认证 | `/v1/auth` | 发码、登录、刷新、登出 | -| 用户 | `/v1/users/me` | 资料 | +| 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 | +| 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) | | 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 | | 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 | | 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 | diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 94085b5..46731d5 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -172,6 +172,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge files := wireFiles(cfg, objects, groups) favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(idgen, db, objects, friends, users) + users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...) app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ Addr: cfg.Server.TCP.Addr, Multicore: cfg.Server.TCP.Multicore, @@ -366,6 +367,45 @@ func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *gro return msglogic.NewService(chat.msgs, chat.chats, chat.p2p, friends, groups, idgen, q) } +func accountCleaners( + friends *friendlogic.Service, + moments *momentlogic.Service, + groups *grouplogic.Service, + favorites *favlogic.Service, + inbox inboxlogic.Store, + mailbox synclogic.Mailbox, + chat chatStores, +) []userlogic.UserDataCleaner { + out := make([]userlogic.UserDataCleaner, 0, 8) + if friends != nil { + out = append(out, friends) + } + if moments != nil { + out = append(out, moments) + } + if groups != nil { + out = append(out, groups) + } + if favorites != nil { + out = append(out, favorites) + } + if inbox != nil { + out = append(out, userlogic.CleanerFunc(inbox.PurgeUser)) + } + if mailbox != nil { + out = append(out, userlogic.CleanerFunc(mailbox.PurgeUser)) + } + if chat.msgs != nil { + out = append(out, userlogic.CleanerFunc(chat.msgs.PurgeUser)) + } + if p, ok := chat.chats.(interface { + PurgeUser(context.Context, int64) error + }); ok { + out = append(out, userlogic.CleanerFunc(p.PurgeUser)) + } + return out +} + func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist { if rdb != nil && rdb.Ready() { return authlogic.NewRedisDenylist(rdb.RDB(), prefix) diff --git a/internal/module/auth/logic/jwt.go b/internal/module/auth/logic/jwt.go index e8fd485..3c3d58c 100644 --- a/internal/module/auth/logic/jwt.go +++ b/internal/module/auth/logic/jwt.go @@ -24,6 +24,7 @@ type Issuer interface { Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error + LogoutAll(ctx context.Context, userID int64) error Revoke(ctx context.Context, token string) error } @@ -231,6 +232,29 @@ func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJ return nil } +func (j *JWT) LogoutAll(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + rows, err := j.pairs.ListByUser(ctx, userID) + if err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + for i := range rows { + row := rows[i] + if row.Pair.Access != "" { + _ = j.denylist.Add(ctx, row.Pair.Access, j.ttl) + } + if row.Pair.Refresh != "" { + _ = j.denylist.Add(ctx, row.Pair.Refresh, j.refreshTTL) + } + if err := j.pairs.Delete(ctx, userID, row.DeviceID); err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + } + return nil +} + func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) { c, err := j.parse(token, false) if err != nil { diff --git a/internal/module/auth/logic/jwt_test.go b/internal/module/auth/logic/jwt_test.go index e4bf03b..ff8d14d 100644 --- a/internal/module/auth/logic/jwt_test.go +++ b/internal/module/auth/logic/jwt_test.go @@ -143,6 +143,31 @@ func TestJWTLogoutRevokesPair(t *testing.T) { } } +func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + first, err := j.Issue(ctx, 8, 1) + if err != nil { + t.Fatal(err) + } + second, err := j.Issue(ctx, 8, 2) + if err != nil { + t.Fatal(err) + } + if err := j.LogoutAll(ctx, 8); err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, first.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("first access: %v", err) + } + if _, err := j.Authenticate(ctx, second.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("second access: %v", err) + } + if _, err := j.Refresh(ctx, first.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("first refresh: %v", err) + } +} + func TestJWTDenylistRevoke(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) diff --git a/internal/module/auth/logic/refresh.go b/internal/module/auth/logic/refresh.go index 59751dc..5a90df3 100644 --- a/internal/module/auth/logic/refresh.go +++ b/internal/module/auth/logic/refresh.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "strconv" + "strings" "sync" "time" @@ -28,6 +29,13 @@ type PairStore interface { Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) Delete(ctx context.Context, userID int64, deviceID uint64) error + ListByUser(ctx context.Context, userID int64) ([]DevicePair, error) +} + +// DevicePair is the live token pair for one device of a user. +type DevicePair struct { + DeviceID uint64 + Pair TokenPair } type MemoryPairs struct { @@ -79,6 +87,33 @@ func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) e return nil } +func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair, error) { + if userID <= 0 { + return nil, nil + } + prefix := strconv.FormatInt(userID, 10) + ":" + now := time.Now().UnixNano() + s.mu.Lock() + defer s.mu.Unlock() + out := make([]DevicePair, 0) + for key := range s.items { + row := s.items[key] + if !strings.HasPrefix(key, prefix) { + continue + } + if now >= row.exp { + delete(s.items, key) + continue + } + did, err := strconv.ParseUint(key[len(prefix):], 10, 64) + if err != nil || did == 0 { + continue + } + out = append(out, DevicePair{DeviceID: did, Pair: row.pair}) + } + return out, nil +} + type RedisPairs struct { rdb *redis.Client prefix string @@ -133,3 +168,44 @@ func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64) } return s.rdb.Del(ctx, s.key(userID, deviceID)).Err() } + +func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair, error) { + if s == nil || s.rdb == nil { + return nil, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready") + } + if userID <= 0 { + return nil, nil + } + prefix := s.prefix + strconv.FormatInt(userID, 10) + ":" + var cursor uint64 + out := make([]DevicePair, 0) + for { + keys, next, err := s.rdb.Scan(ctx, cursor, prefix+"*", 100).Result() + if err != nil { + return nil, err + } + for _, key := range keys { + did, err := strconv.ParseUint(strings.TrimPrefix(key, prefix), 10, 64) + if err != nil || did == 0 { + continue + } + raw, err := s.rdb.Get(ctx, key).Bytes() + if err == redis.Nil { + continue + } + if err != nil { + return nil, err + } + var pair TokenPair + if err := json.Unmarshal(raw, &pair); err != nil { + continue + } + out = append(out, DevicePair{DeviceID: did, Pair: pair}) + } + cursor = next + if cursor == 0 { + break + } + } + return out, nil +} diff --git a/internal/module/conversation/model/memory.go b/internal/module/conversation/model/memory.go index 0a6d5c3..5111dfc 100644 --- a/internal/module/conversation/model/memory.go +++ b/internal/module/conversation/model/memory.go @@ -125,3 +125,15 @@ func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) erro } return nil } + +func (m *MemoryP2P) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + for _, byUser := range m.members { + delete(byUser, userID) + } + return nil +} diff --git a/internal/module/conversation/model/repo.go b/internal/module/conversation/model/repo.go index 143a5aa..4b968da 100644 --- a/internal/module/conversation/model/repo.go +++ b/internal/module/conversation/model/repo.go @@ -121,6 +121,17 @@ func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error { return nil } +func (m *Repo) PurgeUser(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return mapDBErr(db.Where("user_id = ?", userID).Delete(&ConversationMember{}).Error) +} + func (r *Conversation) entity() *entity.Conversation { if r == nil { return nil diff --git a/internal/module/favorite/logic/purge.go b/internal/module/favorite/logic/purge.go new file mode 100644 index 0000000..b001493 --- /dev/null +++ b/internal/module/favorite/logic/purge.go @@ -0,0 +1,28 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.repo == nil || userID <= 0 { + return nil + } + rows, err := s.repo.ListAllByUser(ctx, userID) + if err != nil { + return err + } + for _, row := range rows { + if row == nil { + continue + } + if err := s.repo.Delete(ctx, userID, row.ID); err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + s.deleteFavoriteMedia(ctx, row.Content) + } + return nil +} diff --git a/internal/module/favorite/logic/service.go b/internal/module/favorite/logic/service.go index 8b81f0a..94272b3 100644 --- a/internal/module/favorite/logic/service.go +++ b/internal/module/favorite/logic/service.go @@ -15,6 +15,7 @@ type Repository interface { Create(ctx context.Context, fav *entity.Favorite) error ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error) Delete(ctx context.Context, userID, id int64) error + ListAllByUser(ctx context.Context, userID int64) ([]*entity.Favorite, error) } type Messages interface { diff --git a/internal/module/favorite/model/memory.go b/internal/module/favorite/model/memory.go index 6c76f46..a739ef0 100644 --- a/internal/module/favorite/model/memory.go +++ b/internal/module/favorite/model/memory.go @@ -80,6 +80,19 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]* return out, nil } +func (m *MemoryRepo) ListAllByUser(_ context.Context, userID int64) ([]*entity.Favorite, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Favorite, 0) + for _, row := range m.byID { + if row.UserID == userID { + out = append(out, cloneFav(row)) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + return out, nil +} + func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/module/favorite/model/repo.go b/internal/module/favorite/model/repo.go index a3a9585..8557cc0 100644 --- a/internal/module/favorite/model/repo.go +++ b/internal/module/favorite/model/repo.go @@ -98,6 +98,22 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti return out, nil } +func (m *Repo) ListAllByUser(ctx context.Context, userID int64) ([]*entity.Favorite, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Favorite + if err := db.Where("user_id = ?", userID).Order("id DESC").Limit(10000).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Favorite, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].Entity()) + } + return out, nil +} + func (m *Repo) Delete(ctx context.Context, userID, id int64) error { db, err := m.session(ctx) if err != nil { diff --git a/internal/module/friend/logic/purge.go b/internal/module/friend/logic/purge.go new file mode 100644 index 0000000..ce556ef --- /dev/null +++ b/internal/module/friend/logic/purge.go @@ -0,0 +1,10 @@ +package logic + +import "context" + +func (s *Service) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.repo == nil || userID <= 0 { + return nil + } + return s.repo.PurgeUser(ctx, userID) +} diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go index 2e1c3f1..45da92c 100644 --- a/internal/module/friend/logic/service.go +++ b/internal/module/friend/logic/service.go @@ -26,6 +26,7 @@ type Repository interface { CreateReport(ctx context.Context, r *entity.Report) error AcceptFriend(ctx context.Context, in entity.AcceptParams) error DeleteFriend(ctx context.Context, a, b int64) error + PurgeUser(ctx context.Context, userID int64) error } // UserFinder resolves accounts by uid / email / spark_id / qrcode ticket. diff --git a/internal/module/friend/model/purge.go b/internal/module/friend/model/purge.go new file mode 100644 index 0000000..93c31d3 --- /dev/null +++ b/internal/module/friend/model/purge.go @@ -0,0 +1,55 @@ +package model + +import ( + "context" + + "spark-server/internal/pkg/errs" +) + +func (m *Repo) PurgeUser(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + if err := db.Where("user_id = ? OR friend_id = ?", userID, userID).Delete(&FriendRelation{}).Error; err != nil { + return mapDBErr(err) + } + if err := db.Where("from_user = ? OR to_user = ?", userID, userID).Delete(&FriendRequest{}).Error; err != nil { + return mapDBErr(err) + } + if err := db.Where("user_id = ? OR target_id = ?", userID, userID).Delete(&FriendReport{}).Error; err != nil { + return mapDBErr(err) + } + return nil +} + +func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + for key := range m.relations { + if key.UserID == userID || key.FriendID == userID { + delete(m.relations, key) + } + } + for id, req := range m.requests { + if req == nil || (req.FromUser != userID && req.ToUser != userID) { + continue + } + delete(m.requests, id) + delete(m.byPair, requestKey{From: req.FromUser, To: req.ToUser}) + } + kept := m.reports[:0] + for _, r := range m.reports { + if r != nil && r.UserID != userID && r.TargetID != userID { + kept = append(kept, r) + } + } + m.reports = kept + return nil +} diff --git a/internal/module/group/logic/group_test.go b/internal/module/group/logic/group_test.go index 6282a1e..366d17a 100644 --- a/internal/module/group/logic/group_test.go +++ b/internal/module/group/logic/group_test.go @@ -355,3 +355,79 @@ func TestGroupAdminAnnouncementRemarkMentions(t *testing.T) { t.Fatalf("at outsider %v", err) } } + +func TestPurgeUserTransfersOwnerToAdmin(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + c := login(t, users, "test03@spark.com", 3) + becomeFriends(t, friends, a, b) + becomeFriends(t, friends, a, c) + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{c.ID, b.ID}}) + if err != nil { + t.Fatal(err) + } + admin := entity.RoleAdmin + if _, err := svc.UpdateMember(ctx, a.ID, g.ID, b.ID, &UpdateMemberInput{Role: &admin}); err != nil { + t.Fatal(err) + } + if err := svc.PurgeUser(ctx, a.ID); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctx, b.ID, g.ID) + if err != nil || got.OwnerID != b.ID || got.Role != entity.RoleOwner { + t.Fatalf("successor %+v %v", got, err) + } + if _, err := svc.Get(ctx, a.ID, g.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("owner still member %v", err) + } + if _, err := svc.Get(ctx, c.ID, g.ID); err != nil { + t.Fatal(err) + } +} + +func TestPurgeUserTransfersOwnerToFirstMember(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + c := login(t, users, "test03@spark.com", 3) + becomeFriends(t, friends, a, b) + becomeFriends(t, friends, a, c) + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID, c.ID}}) + if err != nil { + t.Fatal(err) + } + if err := svc.PurgeUser(ctx, a.ID); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctx, b.ID, g.ID) + if err != nil || got.OwnerID != b.ID { + t.Fatalf("first member %+v %v", got, err) + } + if _, err := svc.Get(ctx, a.ID, g.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("owner still member %v", err) + } +} + +func TestPurgeUserDissolvesWhenOwnerAlone(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + becomeFriends(t, friends, a, b) + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID}}) + if err != nil { + t.Fatal(err) + } + if err := svc.RemoveMember(ctx, a.ID, g.ID, b.ID); err != nil { + t.Fatal(err) + } + if err := svc.PurgeUser(ctx, a.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.Get(ctx, a.ID, g.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("solo owner group %v", err) + } +} diff --git a/internal/module/group/logic/purge.go b/internal/module/group/logic/purge.go new file mode 100644 index 0000000..a5afd6e --- /dev/null +++ b/internal/module/group/logic/purge.go @@ -0,0 +1,73 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.repo == nil || userID <= 0 { + return nil + } + groups, err := s.repo.ListAllActiveByUser(ctx, userID) + if err != nil { + return err + } + for _, g := range groups { + if g == nil { + continue + } + if g.OwnerID == userID { + if err := s.handOffOwnedGroup(ctx, userID, g); err != nil { + return err + } + continue + } + if err := s.repo.RemoveMember(ctx, g.ID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + s.invalidateMembers(g.ConversationID) + } + return nil +} + +func (s *Service) handOffOwnedGroup(ctx context.Context, ownerID int64, g *entity.Group) error { + members, err := s.repo.ListMembers(ctx, g.ID, entity.MemberListMax) + if err != nil { + return err + } + next := successorOwnerID(ownerID, members) + if next == 0 { + err := s.Dissolve(ctx, ownerID, g.ID) + if err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + return nil + } + if _, err := s.Transfer(ctx, ownerID, g.ID, &TransferInput{UID: next}); err != nil { + return err + } + if err := s.RemoveMember(ctx, ownerID, g.ID, ownerID); err != nil && !errors.Is(err, errs.ErrNotFound) { + return err + } + return nil +} + +func successorOwnerID(ownerID int64, members []*entity.Member) int64 { + var firstOther int64 + for _, m := range members { + if m == nil || m.UserID == ownerID { + continue + } + if m.Role == entity.RoleAdmin { + return m.UserID + } + if firstOther == 0 { + firstOther = m.UserID + } + } + return firstOther +} diff --git a/internal/module/group/logic/service.go b/internal/module/group/logic/service.go index 97da5b1..e1c7f63 100644 --- a/internal/module/group/logic/service.go +++ b/internal/module/group/logic/service.go @@ -21,6 +21,7 @@ type Repository interface { Get(ctx context.Context, id int64) (*entity.Group, error) GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error) + ListAllActiveByUser(ctx context.Context, userID int64) ([]*entity.Group, error) UpdateProfile(ctx context.Context, g *entity.Group) error GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error) diff --git a/internal/module/group/model/group.go b/internal/module/group/model/group.go index 4ffc737..e58ded1 100644 --- a/internal/module/group/model/group.go +++ b/internal/module/group/model/group.go @@ -112,6 +112,31 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti return out, nil } +const purgeListCap = 10000 + +func (m *Repo) ListAllActiveByUser(ctx context.Context, userID int64) ([]*entity.Group, error) { + if userID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Group + err = db.Table("groups AS g").Select("g.*"). + Joins("INNER JOIN group_members m ON m.group_id = g.id"). + Where("m.user_id = ? AND g.status = ?", userID, entity.StatusActive). + Order("g.updated_at DESC").Limit(purgeListCap).Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Group, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} + func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error { if g == nil || g.ID <= 0 { return errs.ErrInvalidArgument diff --git a/internal/module/group/model/group_member.go b/internal/module/group/model/group_member.go index 9141120..696ad9b 100644 --- a/internal/module/group/model/group_member.go +++ b/internal/module/group/model/group_member.go @@ -73,7 +73,7 @@ func (m *Repo) ListMembers(ctx context.Context, groupID int64, limit int) ([]*en } limit = entity.ClampMemberLimit(limit) var rows []GroupMember - err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC").Limit(limit).Find(&rows).Error + err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC, id ASC").Limit(limit).Find(&rows).Error if err != nil { return nil, mapDBErr(err) } diff --git a/internal/module/group/model/memory.go b/internal/module/group/model/memory.go index d1fe7f2..ae1b603 100644 --- a/internal/module/group/model/memory.go +++ b/internal/module/group/model/memory.go @@ -73,6 +73,25 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]* return out, nil } +func (m *MemoryRepo) ListAllActiveByUser(_ context.Context, userID int64) ([]*entity.Group, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Group, 0) + for gid, byUser := range m.members { + if _, ok := byUser[userID]; !ok { + continue + } + g, ok := m.groups[gid] + if !ok || g.Status != entity.StatusActive { + continue + } + cp := *g + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAtMS > out[j].UpdatedAtMS }) + return out, nil +} + func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) { m.mu.Lock() defer m.mu.Unlock() @@ -105,7 +124,10 @@ func (m *MemoryRepo) ListMembers(_ context.Context, groupID int64, limit int) ([ if out[i].Role != out[j].Role { return out[i].Role < out[j].Role } - return out[i].JoinedAtMS < out[j].JoinedAtMS + if out[i].JoinedAtMS != out[j].JoinedAtMS { + return out[i].JoinedAtMS < out[j].JoinedAtMS + } + return out[i].UserID < out[j].UserID }) if len(out) > limit { out = out[:limit] diff --git a/internal/module/inbox/logic/store.go b/internal/module/inbox/logic/store.go index 3a3e1b2..4a6c74f 100644 --- a/internal/module/inbox/logic/store.go +++ b/internal/module/inbox/logic/store.go @@ -14,4 +14,5 @@ type Store interface { SetPreview(ctx context.Context, conversationID int64, preview []byte) error ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error) LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error) + PurgeUser(ctx context.Context, userID int64) error } diff --git a/internal/module/inbox/model/memory.go b/internal/module/inbox/model/memory.go index 5ee2830..45d9e2f 100644 --- a/internal/module/inbox/model/memory.go +++ b/internal/module/inbox/model/memory.go @@ -109,6 +109,16 @@ func (s *MemoryStore) LoadDialogs(_ context.Context, userID int64, hits []entity return out, nil } +func (s *MemoryStore) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byUser, userID) + return nil +} + func (s *MemoryStore) Preview(conversationID int64) []byte { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/module/inbox/model/redis.go b/internal/module/inbox/model/redis.go index 0feb253..5540dfd 100644 --- a/internal/module/inbox/model/redis.go +++ b/internal/module/inbox/model/redis.go @@ -128,3 +128,10 @@ func (s *RedisStore) LoadDialogs(ctx context.Context, userID int64, hits []entit } return out, nil } + +func (s *RedisStore) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.rdb == nil || userID <= 0 { + return nil + } + return s.rdb.Del(ctx, unreadKey(userID), readKey(userID), countKey(userID)).Err() +} diff --git a/internal/module/message/logic/service.go b/internal/module/message/logic/service.go index c4008e2..2c67ac4 100644 --- a/internal/module/message/logic/service.go +++ b/internal/module/message/logic/service.go @@ -23,6 +23,7 @@ type Repository interface { ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) Recall(ctx context.Context, id int64) error HideForUser(ctx context.Context, userID int64, msg *entity.Message) error + PurgeUser(ctx context.Context, userID int64) error } type Conversations interface { diff --git a/internal/module/message/model/memory.go b/internal/module/message/model/memory.go index b323646..256d682 100644 --- a/internal/module/message/model/memory.go +++ b/internal/module/message/model/memory.go @@ -135,6 +135,20 @@ func (m *MemoryRepo) HideForUser(_ context.Context, userID int64, msg *entity.Me return nil } +func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + for key := range m.hidden { + if key.UserID == userID { + delete(m.hidden, key) + } + } + return nil +} + func (m *MemoryRepo) All() []*entity.Message { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/module/message/model/query.go b/internal/module/message/model/query.go index b37f38c..f57e409 100644 --- a/internal/module/message/model/query.go +++ b/internal/module/message/model/query.go @@ -110,3 +110,14 @@ func (m *Repo) HideForUser(ctx context.Context, userID int64, msg *entity.Messag } return nil } + +func (m *Repo) PurgeUser(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return mapDBErr(db.Where("user_id = ?", userID).Delete(&MessageDelete{}).Error) +} diff --git a/internal/module/moment/logic/purge.go b/internal/module/moment/logic/purge.go new file mode 100644 index 0000000..e9ca1a0 --- /dev/null +++ b/internal/module/moment/logic/purge.go @@ -0,0 +1,31 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/moment/entity" +) + +func (s *Service) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.repo == nil || userID <= 0 { + return nil + } + posts, err := s.repo.ListAllByAuthor(ctx, userID) + if err != nil { + return err + } + st, err := s.repo.GetSettings(ctx, userID) + if err != nil { + return err + } + if err := s.repo.PurgeUser(ctx, userID); err != nil { + return err + } + for _, post := range posts { + s.deleteMedia(ctx, post) + } + if s.objects != nil && st != nil && st.CoverKey != "" && entity.ValidCoverKey(st.CoverKey) { + _ = s.objects.Delete(ctx, st.CoverKey) + } + return nil +} diff --git a/internal/module/moment/logic/service.go b/internal/module/moment/logic/service.go index 1453d80..f30637b 100644 --- a/internal/module/moment/logic/service.go +++ b/internal/module/moment/logic/service.go @@ -35,6 +35,8 @@ type Repository interface { HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error) UpsertHide(ctx context.Context, h *entity.Hide) error DeleteHide(ctx context.Context, userID, peerID int64) error + ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error) + PurgeUser(ctx context.Context, userID int64) error } // Friends is the friend graph used for visibility. Friend module implements it. diff --git a/internal/module/moment/model/memory_purge.go b/internal/module/moment/model/memory_purge.go new file mode 100644 index 0000000..af992ec --- /dev/null +++ b/internal/module/moment/model/memory_purge.go @@ -0,0 +1,92 @@ +package model + +import ( + "context" + + "spark-server/internal/module/moment/entity" + "spark-server/internal/pkg/errs" +) + +func (m *MemoryRepo) ListAllByAuthor(_ context.Context, authorID int64) ([]*entity.Post, error) { + if authorID <= 0 { + return nil, errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Post, 0) + for _, row := range m.posts { + if row.UserID == authorID { + out = append(out, clonePost(row)) + } + } + return out, nil +} + +func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + owned := map[int64]bool{} + for id, post := range m.posts { + if post.UserID == userID { + owned[id] = true + } + } + for _, like := range m.likes { + if like.UserID != userID || owned[like.PostID] { + continue + } + if post, ok := m.posts[like.PostID]; ok && post.LikeCount > 0 { + post.LikeCount-- + } + } + for _, c := range m.comments { + if c.UserID != userID || owned[c.PostID] || c.Status == entity.StatusDeleted { + continue + } + if post, ok := m.posts[c.PostID]; ok && post.CommentCount > 0 { + post.CommentCount-- + } + } + for key, like := range m.likes { + if like.UserID == userID || owned[like.PostID] { + delete(m.likes, key) + } + } + for id, c := range m.comments { + if c.UserID == userID || owned[c.PostID] { + delete(m.comments, id) + } + } + for id, n := range m.notices { + if n.ToUserID == userID || n.FromUserID == userID || owned[n.PostID] { + delete(m.notices, id) + } + } + for key, h := range m.hides { + if h.UserID == userID || h.PeerID == userID { + delete(m.hides, key) + } + } + for postID, peers := range m.audience { + if owned[postID] { + delete(m.audience, postID) + continue + } + kept := peers[:0] + for _, peer := range peers { + if peer != userID { + kept = append(kept, peer) + } + } + m.audience[postID] = kept + } + delete(m.settings, userID) + for id := range owned { + delete(m.posts, id) + delete(m.audience, id) + } + return nil +} diff --git a/internal/module/moment/model/purge.go b/internal/module/moment/model/purge.go new file mode 100644 index 0000000..0e73b8b --- /dev/null +++ b/internal/module/moment/model/purge.go @@ -0,0 +1,105 @@ +package model + +import ( + "context" + + "gorm.io/gorm" + + "spark-server/internal/module/moment/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error) { + if authorID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Post + if err := db.Where("user_id = ?", authorID).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + return collectPosts(rows), nil +} + +func (m *Repo) PurgeUser(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + return purgeMomentUser(tx, userID) + }) +} + +func purgeMomentUser(tx *gorm.DB, userID int64) error { + if err := bumpOffUserLikes(tx, userID); err != nil { + return err + } + if err := bumpOffUserComments(tx, userID); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&Like{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&Comment{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("to_user_id = ? OR from_user_id = ?", userID, userID).Delete(&Notice{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ? OR peer_id = ?", userID, userID).Delete(&Hide{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("peer_id = ?", userID).Delete(&Audience{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&Setting{}).Error); err != nil { + return err + } + return deleteAuthoredPosts(tx, userID) +} + +func bumpOffUserLikes(tx *gorm.DB, userID int64) error { + sql := `UPDATE moment_posts p +INNER JOIN (SELECT post_id, COUNT(*) AS n FROM moment_likes WHERE user_id = ? GROUP BY post_id) x ON p.id = x.post_id +SET p.like_count = CASE WHEN p.like_count < x.n THEN 0 ELSE p.like_count - x.n END +WHERE p.user_id <> ?` + return mapDBErr(tx.Exec(sql, userID, userID).Error) +} + +func bumpOffUserComments(tx *gorm.DB, userID int64) error { + sql := `UPDATE moment_posts p +INNER JOIN (SELECT post_id, COUNT(*) AS n FROM moment_comments WHERE user_id = ? AND status = ? GROUP BY post_id) x ON p.id = x.post_id +SET p.comment_count = CASE WHEN p.comment_count < x.n THEN 0 ELSE p.comment_count - x.n END +WHERE p.user_id <> ?` + return mapDBErr(tx.Exec(sql, userID, entity.StatusNormal, userID).Error) +} + +func deleteAuthoredPosts(tx *gorm.DB, userID int64) error { + var ids []int64 + if err := tx.Model(&Post{}).Where("user_id = ?", userID).Pluck("id", &ids).Error; err != nil { + return mapDBErr(err) + } + if len(ids) == 0 { + return nil + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Audience{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Like{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Comment{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Notice{}).Error); err != nil { + return err + } + return mapDBErr(tx.Where("id IN ?", ids).Delete(&Post{}).Error) +} diff --git a/internal/module/sync/logic/service.go b/internal/module/sync/logic/service.go index 7cb7d7d..ff780fa 100644 --- a/internal/module/sync/logic/service.go +++ b/internal/module/sync/logic/service.go @@ -16,6 +16,7 @@ type Mailbox interface { Put(ctx context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error ListSince(ctx context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error) SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error + PurgeUser(ctx context.Context, userID int64) error } type InboxResponse struct { diff --git a/internal/module/sync/model/memory.go b/internal/module/sync/model/memory.go index 578040b..5e55e7e 100644 --- a/internal/module/sync/model/memory.go +++ b/internal/module/sync/model/memory.go @@ -94,3 +94,25 @@ func (s *MemoryStore) SaveCursor(_ context.Context, userID int64, deviceID uint6 s.cursors[[2]int64{userID, int64(deviceID)}] = memCursor{MailboxSeq: mailboxSeq, DialogScore: dialogScore} return nil } + +func (s *MemoryStore) PurgeUser(_ context.Context, userID int64) error { + if s == nil || userID <= 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.seq, userID) + kept := s.events[:0] + for i := range s.events { + if s.events[i].UserID != userID { + kept = append(kept, s.events[i]) + } + } + s.events = kept + for key := range s.cursors { + if key[0] == userID { + delete(s.cursors, key) + } + } + return nil +} diff --git a/internal/module/sync/model/store.go b/internal/module/sync/model/store.go index 55b33de..123fcdd 100644 --- a/internal/module/sync/model/store.go +++ b/internal/module/sync/model/store.go @@ -125,3 +125,17 @@ func (s *Store) SaveCursor(ctx context.Context, userID int64, deviceID uint64, m DoUpdates: clause.AssignmentColumns([]string{"mailbox_seq", "dialog_score", "updated_at"}), }).Create(&row).Error) } + +func (s *Store) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || userID <= 0 { + return nil + } + db, err := s.session(ctx) + if err != nil { + return err + } + if err := db.Where("user_id = ?", userID).Delete(&MailboxEvent{}).Error; err != nil { + return mapDBErr(err) + } + return mapDBErr(db.Where("user_id = ?", userID).Delete(&DeviceSyncCursor{}).Error) +} diff --git a/internal/module/user/entity/deleted.go b/internal/module/user/entity/deleted.go new file mode 100644 index 0000000..9f59264 --- /dev/null +++ b/internal/module/user/entity/deleted.go @@ -0,0 +1,34 @@ +package entity + +import "time" + +// Deleted is a snapshot of a canceled account, persisted in user_delete. +type Deleted struct { + ID int64 + SparkID string + Nickname string + Avatar string + QRCode string + Birthday time.Time + Gender Gender + Status Status + Email string + LastLoginAt time.Time + LastActiveAt time.Time + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time +} + +// SnapshotDeleted copies a live account into an archive row. +func SnapshotDeleted(u *User, email string, at time.Time) *Deleted { + if u == nil { + return nil + } + return &Deleted{ + ID: u.ID, SparkID: u.SparkID, Nickname: u.Nickname, Avatar: u.Avatar, QRCode: u.QRCode, + Birthday: u.Birthday, Gender: u.Gender, Status: u.Status, Email: email, + LastLoginAt: u.LastLoginAt, LastActiveAt: u.LastActiveAt, + CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, DeletedAt: at, + } +} diff --git a/internal/module/user/logic/delete.go b/internal/module/user/logic/delete.go new file mode 100644 index 0000000..67bf190 --- /dev/null +++ b/internal/module/user/logic/delete.go @@ -0,0 +1,124 @@ +package logic + +import ( + "context" + "errors" + "strings" + "time" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +// UserDataCleaner removes a canceled user's rows in another module. +type UserDataCleaner interface { + PurgeUser(ctx context.Context, userID int64) error +} + +// TokenRevoker drops every live JWT pair for a user. Auth JWT implements it. +type TokenRevoker interface { + LogoutAll(ctx context.Context, userID int64) error +} + +// SessionDropper kicks every TCP connection for a user. +type SessionDropper interface { + KickUser(userID int64) +} + +type cleanerFunc func(context.Context, int64) error + +func (f cleanerFunc) PurgeUser(ctx context.Context, userID int64) error { + if f == nil { + return nil + } + return f(ctx, userID) +} + +// CleanerFunc adapts a function to UserDataCleaner. +func CleanerFunc(fn func(context.Context, int64) error) UserDataCleaner { + return cleanerFunc(fn) +} + +// BindAccountCleanup wires token revoke, TCP kick, and cross-module purge for 注销. +func (s *Service) BindAccountCleanup(tokens TokenRevoker, sessions SessionDropper, cleaners ...UserDataCleaner) { + if s == nil { + return + } + s.tokens = tokens + s.sessions = sessions + s.cleaners = append(s.cleaners[:0], cleaners...) +} + +func (s *Service) DeleteAccount(ctx context.Context, userID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + u, err := s.repo.GetByID(ctx, userID) + if err != nil { + return err + } + email, err := s.lookupEmail(ctx, userID) + if err != nil { + return err + } + s.revokeAccess(ctx, userID) + if err := s.purgeRelated(ctx, userID); err != nil { + return err + } + if err := s.repo.ArchiveAndDelete(ctx, entity.SnapshotDeleted(u, email, time.Now())); err != nil { + return err + } + s.deleteAvatarObject(ctx, u.Avatar) + if email != "" && s.codes != nil { + _ = s.codes.Del(ctx, email) + } + return nil +} + +func (s *Service) lookupEmail(ctx context.Context, userID int64) (string, error) { + email, err := s.repo.GetEmail(ctx, userID) + if err == nil { + return email, nil + } + if errors.Is(err, errs.ErrNotFound) { + return "", nil + } + return "", err +} + +func (s *Service) revokeAccess(ctx context.Context, userID int64) { + if s.tokens != nil { + _ = s.tokens.LogoutAll(ctx, userID) + } + if s.sessions != nil { + s.sessions.KickUser(userID) + } +} + +func (s *Service) purgeRelated(ctx context.Context, userID int64) error { + for _, c := range s.cleaners { + if c == nil { + continue + } + if err := c.PurgeUser(ctx, userID); err != nil { + return err + } + } + return nil +} + +type objectRemover interface { + Delete(ctx context.Context, key string) error +} + +func (s *Service) deleteAvatarObject(ctx context.Context, avatar string) { + key := strings.TrimSpace(avatar) + if !strings.HasPrefix(key, "perm/avatar/") { + return + } + rm, ok := s.objects.(objectRemover) + if !ok || rm == nil { + return + } + _ = rm.Delete(ctx, key) +} diff --git a/internal/module/user/logic/delete_test.go b/internal/module/user/logic/delete_test.go new file mode 100644 index 0000000..81b843a --- /dev/null +++ b/internal/module/user/logic/delete_test.go @@ -0,0 +1,95 @@ +package logic + +import ( + "context" + "errors" + "testing" + + "spark-server/internal/infrastructure/id" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + momentlogic "spark-server/internal/module/moment/logic" + momentmodel "spark-server/internal/module/moment/model" + "spark-server/internal/module/user/entity" + "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +func TestDeleteAccountArchivesAndAllowsReregister(t *testing.T) { + svc, repo, _ := testService(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10", Language: "zh-CN"}) + device := &entity.LoginDevice{DeviceID: 7, DeviceType: entity.DeviceIOS} + u, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if err := svc.DeleteAccount(ctx, u.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.GetByID(ctx, u.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("live user %v", err) + } + if _, err := repo.GetByEmail(ctx, "test01@spark.com"); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("email still bound %v", err) + } + archived, err := repo.GetDeleted(ctx, u.ID) + if err != nil || archived.Email != "test01@spark.com" || archived.SparkID != u.SparkID { + t.Fatalf("archive %+v %v", archived, err) + } + again, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if again.ID == u.ID { + t.Fatal("uid reused") + } +} + +func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + repo := model.NewMemoryRepo() + users := NewService(repo, model.NewMemoryCodes(), ids, nil) + friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil) + moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids) + users.BindAccountCleanup(nil, nil, friends, moments) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "10.0.0.1", Language: "zh-CN"}) + a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &entity.LoginDevice{DeviceID: 2, DeviceType: entity.DeviceWeb}) + if err != nil { + t.Fatal(err) + } + applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{Email: "test02@spark.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + ctxA := reqctx.With(ctx, &reqctx.RequestContext{UserID: a.ID, RealIP: "10.0.0.1", Language: "zh-CN"}) + pub, err := moments.Publish(ctxA, a.ID, &momentlogic.PublishInput{Text: "bye"}) + if err != nil || pub == nil { + t.Fatalf("publish %+v %v", pub, err) + } + if err := users.DeleteAccount(ctx, a.ID); err != nil { + t.Fatal(err) + } + ok, err := friends.AreFriends(ctx, a.ID, b.ID) + if err != nil || ok { + t.Fatalf("friends after cancel %v %v", ok, err) + } + if _, err := moments.Get(ctx, b.ID, pub.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("moment still visible %v", err) + } +} + +func (m *memObjects) Delete(_ context.Context, key string) error { + delete(m.kv, key) + return nil +} diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go index 208076e..c49a1cd 100644 --- a/internal/module/user/logic/service.go +++ b/internal/module/user/logic/service.go @@ -22,6 +22,9 @@ type Repository interface { UpsertDevice(ctx context.Context, d *entity.Device) error GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) InsertLoginLog(ctx context.Context, log *entity.LoginLog) error + GetEmail(ctx context.Context, userID int64) (string, error) + GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error) + ArchiveAndDelete(ctx context.Context, d *entity.Deleted) error } // IDGen issues time-ordered Snowflake IDs (user PK). @@ -36,12 +39,19 @@ type Objects interface { // Service is the user application use-case (HTTP and TCP share it). type Service struct { - repo Repository - codes CodeStore - ids IDGen - objects Objects + repo Repository + codes CodeStore + ids IDGen + objects Objects + tokens TokenRevoker + sessions SessionDropper + cleaners []UserDataCleaner } func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service { return &Service{repo: repo, codes: codes, ids: ids, objects: objects} } + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.ids != nil +} diff --git a/internal/module/user/model/memory.go b/internal/module/user/model/memory.go index d758641..b698ae7 100644 --- a/internal/module/user/model/memory.go +++ b/internal/module/user/model/memory.go @@ -16,6 +16,7 @@ type MemoryRepo struct { bySpark map[string]int64 devices map[deviceKey]*entity.Device logs []*entity.LoginLog + deleted map[int64]*entity.Deleted deviceSeq int64 logSeq int64 } @@ -32,6 +33,7 @@ func NewMemoryRepo() *MemoryRepo { bySpark: make(map[string]int64), devices: make(map[deviceKey]*entity.Device), logs: make([]*entity.LoginLog, 0), + deleted: make(map[int64]*entity.Deleted), } } @@ -207,6 +209,71 @@ func (m *MemoryRepo) InsertLoginLog(_ context.Context, log *entity.LoginLog) err return nil } +func (m *MemoryRepo) GetEmail(_ context.Context, userID int64) (string, error) { + if userID <= 0 { + return "", errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + for email, id := range m.byEmail { + if id == userID { + return email, nil + } + } + return "", errs.ErrNotFound +} + +func (m *MemoryRepo) GetDeleted(_ context.Context, id int64) (*entity.Deleted, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.deleted[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *row + return &cp, nil +} + +func (m *MemoryRepo) ArchiveAndDelete(_ context.Context, d *entity.Deleted) error { + if d == nil || d.ID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[d.ID] + if !ok { + return errs.ErrNotFound + } + email := d.Email + for em, id := range m.byEmail { + if id == d.ID { + email = em + delete(m.byEmail, em) + break + } + } + if d.DeletedAt.IsZero() { + d.DeletedAt = time.Now() + } + snap := entity.SnapshotDeleted(u, email, d.DeletedAt) + m.deleted[d.ID] = snap + delete(m.byID, d.ID) + delete(m.bySpark, u.SparkID) + for key := range m.devices { + if key.UserID == d.ID { + delete(m.devices, key) + } + } + kept := m.logs[:0] + for _, log := range m.logs { + if log != nil && log.UserID != d.ID { + kept = append(kept, log) + } + } + m.logs = kept + return nil +} + func (m *MemoryRepo) LoginLogs() []*entity.LoginLog { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/module/user/model/user_delete.go b/internal/module/user/model/user_delete.go new file mode 100644 index 0000000..5a80fdd --- /dev/null +++ b/internal/module/user/model/user_delete.go @@ -0,0 +1,143 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +// UserDelete maps table user_delete: archived row after account cancellation. +type UserDelete struct { + ID int64 `gorm:"column:id;primaryKey;comment:原 users.id(uid),Snowflake,不复用"` + SparkID string `gorm:"column:spark_id;comment:注销时的 Spark 账号"` + Nickname string `gorm:"column:nickname;comment:注销时的昵称"` + Avatar string `gorm:"column:avatar;comment:注销时的头像 object_key"` + QRCode string `gorm:"column:qrcode;comment:注销时的个人二维码票据"` + Birthday *time.Time `gorm:"column:birthday;type:date;comment:注销时的生日"` + Gender int `gorm:"column:gender;comment:0=未知 1=男 2=女"` + Status int `gorm:"column:status;comment:注销前账号状态:1=正常 0=禁用"` + Email string `gorm:"column:email;comment:注销时绑定的邮箱,注销后可重新注册"` + LastLoginAt *time.Time `gorm:"column:last_login_at;comment:注销前最近登录时间"` + LastActiveAt *time.Time `gorm:"column:last_active_at;comment:注销前最近活跃时间"` + CreatedAt time.Time `gorm:"column:created_at;comment:原注册时间"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:原资料更新时间"` + DeletedAt time.Time `gorm:"column:deleted_at;comment:注销时间"` +} + +func (UserDelete) TableName() string { return "user_delete" } + +func (r *UserDelete) entity() *entity.Deleted { + if r == nil { + return nil + } + return &entity.Deleted{ + ID: r.ID, SparkID: r.SparkID, Nickname: r.Nickname, Avatar: r.Avatar, QRCode: r.QRCode, + Birthday: timeOrZero(r.Birthday), Gender: entity.Gender(r.Gender), Status: entity.Status(r.Status), + Email: r.Email, LastLoginAt: timeOrZero(r.LastLoginAt), LastActiveAt: timeOrZero(r.LastActiveAt), + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, DeletedAt: r.DeletedAt, + } +} + +func deletedFrom(d *entity.Deleted) UserDelete { + row := UserDelete{ + ID: d.ID, SparkID: d.SparkID, Nickname: d.Nickname, Avatar: d.Avatar, QRCode: d.QRCode, + Gender: int(d.Gender), Status: int(d.Status), Email: d.Email, + CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, DeletedAt: d.DeletedAt, + } + row.Birthday = timePtr(d.Birthday) + row.LastLoginAt = timePtr(d.LastLoginAt) + row.LastActiveAt = timePtr(d.LastActiveAt) + return row +} + +func (m *Repo) GetEmail(ctx context.Context, userID int64) (string, error) { + if userID <= 0 { + return "", errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return "", err + } + var bind UserEmail + if err := db.Where("user_id = ?", userID).Take(&bind).Error; err != nil { + return "", mapDBErr(err) + } + return bind.Email, nil +} + +func (m *Repo) GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error) { + if id <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row UserDelete + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) ArchiveAndDelete(ctx context.Context, d *entity.Deleted) error { + if d == nil || d.ID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + if d.DeletedAt.IsZero() { + d.DeletedAt = time.Now() + } + return db.Transaction(func(tx *gorm.DB) error { + return archiveUserTx(tx, d) + }) +} + +func archiveUserTx(tx *gorm.DB, d *entity.Deleted) error { + var live User + if err := tx.Where("id = ?", d.ID).Take(&live).Error; err != nil { + return mapDBErr(err) + } + email := d.Email + var bind UserEmail + if err := tx.Where("user_id = ?", d.ID).Take(&bind).Error; err == nil { + email = bind.Email + } + d.Email = email + row := deletedFrom(d) + row.SparkID = live.SparkID + row.Nickname = live.Nickname + row.Avatar = live.Avatar + row.QRCode = live.QRCode + row.Birthday = live.Birthday + row.Gender = live.Gender + row.Status = live.Status + row.LastLoginAt = live.LastLoginAt + row.LastActiveAt = live.LastActiveAt + row.CreatedAt = live.CreatedAt + row.UpdatedAt = live.UpdatedAt + row.Email = email + if err := tx.Create(&row).Error; err != nil { + return mapDBErr(err) + } + if err := tx.Where("user_id = ?", d.ID).Delete(&UserEmail{}).Error; err != nil { + return mapDBErr(err) + } + if err := tx.Where("user_id = ?", d.ID).Delete(&UserDevice{}).Error; err != nil { + return mapDBErr(err) + } + if err := tx.Where("user_id = ?", d.ID).Delete(&UserLoginLog{}).Error; err != nil { + return mapDBErr(err) + } + if err := tx.Where("id = ?", d.ID).Delete(&User{}).Error; err != nil { + return mapDBErr(err) + } + return nil +} diff --git a/internal/pkg/i18n/catalog_notify.go b/internal/pkg/i18n/catalog_notify.go index 8666063..a3a3df8 100644 --- a/internal/pkg/i18n/catalog_notify.go +++ b/internal/pkg/i18n/catalog_notify.go @@ -8,6 +8,9 @@ func init() { NotifyKickLogout: { ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出", }, + NotifyKickDeleted: { + ZhCN: "账号已注销", En: "Account deleted", ZhTW: "帳號已註銷", + }, NotifyFriendRequest + ".title": { ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求", }, diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index f5c9b71..39e192a 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -148,6 +148,7 @@ const ( NotifyKickReplaced = "notify.kick.replaced" NotifyKickLogout = "notify.kick.logout" + NotifyKickDeleted = "notify.kick.deleted" NotifyFriendRequest = "notify.friend.request" NotifyFriendBecame = "notify.friend.became" NotifyGroupInvite = "notify.group.invite" diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 349c762..9a78b51 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -688,6 +688,21 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + delete: + tags: [users] + summary: 注销当前账号 + description: | + 吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。 + 同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。 + 对方仍可见历史消息(不删 `messages`)。 + operationId: deleteMe + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/friends/lookup: get: tags: [friends] diff --git a/internal/transport/http/handler/user/handler.go b/internal/transport/http/handler/user/handler.go index 2839f2f..e8d1e44 100644 --- a/internal/transport/http/handler/user/handler.go +++ b/internal/transport/http/handler/user/handler.go @@ -50,3 +50,16 @@ func (h *Handler) PatchMe(c *gin.Context) { } respond.JSON(c, userlogic.PublicProfile(u)) } + +func (h *Handler) DeleteMe(c *gin.Context) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return + } + if err := h.users.DeleteAccount(c.Request.Context(), rc.UserID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 4cc5f51..8f8f509 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -86,6 +86,7 @@ func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g := v1.Group("/users") g.GET("/me", h.Me) g.PATCH("/me", h.PatchMe) + g.DELETE("/me", h.DeleteMe) } func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index 4e36458..c46f766 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -151,6 +151,7 @@ func testLoginEngine(t *testing.T) *gin.Engine { t.Fatal(err) } users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users.BindAccountCleanup(j, nil) return NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogic.NewLoginService(j, users), @@ -357,6 +358,68 @@ func TestRefreshAndLogout(t *testing.T) { } } +func TestDeleteAccount(t *testing.T) { + r := testLoginEngine(t) + w := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) + loginReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, loginReq) + var loginEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { + t.Fatal(err) + } + if loginEnv.Code != "0" { + t.Fatalf("login %+v", loginEnv) + } + var tok authentity.IssuedToken + if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { + t.Fatal(err) + } + + w = httptest.NewRecorder() + delReq := httptest.NewRequest(http.MethodDelete, "/v1/users/me", nil) + delReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, delReq) + var delEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &delEnv); err != nil { + t.Fatal(err) + } + if delEnv.Code != "0" { + t.Fatalf("delete %+v", delEnv) + } + + w = httptest.NewRecorder() + meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) + meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, meReq) + var meEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil { + t.Fatal(err) + } + if meEnv.Code != errs.ErrUnauthorized.Code { + t.Fatalf("after delete %+v", meEnv) + } + + w = httptest.NewRecorder() + again := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) + again.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, again) + var againEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &againEnv); err != nil { + t.Fatal(err) + } + if againEnv.Code != "0" { + t.Fatalf("re-register %+v", againEnv) + } + var next authentity.IssuedToken + if err := json.Unmarshal(againEnv.Data, &next); err != nil { + t.Fatal(err) + } + if next.UserID == tok.UserID { + t.Fatal("uid reused") + } +} + func TestLoginRejectsBadInput(t *testing.T) { r := testLoginEngine(t) w := httptest.NewRecorder() diff --git a/internal/transport/tcp/session/conn_test.go b/internal/transport/tcp/session/conn_test.go index 09564ee..aa8d95e 100644 --- a/internal/transport/tcp/session/conn_test.go +++ b/internal/transport/tcp/session/conn_test.go @@ -69,3 +69,24 @@ func TestKickDevice(t *testing.T) { t.Fatal("expected empty") } } + +func TestKickUser(t *testing.T) { + m := NewManager() + a := NewConn("s1", "10.0.0.1:1", 8) + a.Authenticate(3, 4) + b := NewConn("s2", "10.0.0.1:2", 8) + b.Authenticate(3, 5) + if err := m.Bind(a); err != nil { + t.Fatal(err) + } + if err := m.Bind(b); err != nil { + t.Fatal(err) + } + m.KickUser(3) + if a.State() != StateClosed || b.State() != StateClosed { + t.Fatalf("state %d %d", a.State(), b.State()) + } + if len(m.GetUserSessions(3)) != 0 { + t.Fatal("expected empty") + } +} diff --git a/internal/transport/tcp/session/kick.go b/internal/transport/tcp/session/kick.go index 9cb4833..87ebeb2 100644 --- a/internal/transport/tcp/session/kick.go +++ b/internal/transport/tcp/session/kick.go @@ -53,3 +53,22 @@ func (m *memManager) KickDevice(userID int64, deviceID uint64) { delete(userMap, id) } } + +func (m *memManager) KickUser(userID int64) { + m.mu.Lock() + defer m.mu.Unlock() + userMap := m.byUser[userID] + if userMap == nil { + return + } + for id, existing := range userMap { + reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickDeleted, nil) + _ = existing.Send(&Packet{ + Command: protocol.CmdKick, + Body: protocol.EncodeKick(existing.RequestID(), reason), + }) + _ = existing.Close() + delete(m.byID, id) + } + delete(m.byUser, userID) +} diff --git a/internal/transport/tcp/session/manager.go b/internal/transport/tcp/session/manager.go index 3da0d8a..2f9de5e 100644 --- a/internal/transport/tcp/session/manager.go +++ b/internal/transport/tcp/session/manager.go @@ -31,6 +31,7 @@ type Manager interface { Get(sessionID string) (Session, bool) GetUserSessions(userID int64) []Session KickDevice(userID int64, deviceID uint64) + KickUser(userID int64) SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error } diff --git a/migrations/0017_user_delete.sql b/migrations/0017_user_delete.sql new file mode 100644 index 0000000..2bbab6b --- /dev/null +++ b/migrations/0017_user_delete.sql @@ -0,0 +1,21 @@ +-- Archived accounts. Live rows stay in users; cancellation copies then deletes. + +CREATE TABLE IF NOT EXISTS user_delete ( + id BIGINT PRIMARY KEY COMMENT '原 users.id(uid),Snowflake,不复用', + spark_id VARCHAR(32) NOT NULL COMMENT '注销时的 Spark 账号', + nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注销时的昵称', + avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '注销时的头像 object_key', + qrcode VARCHAR(512) NOT NULL DEFAULT '' COMMENT '注销时的个人二维码票据', + birthday DATE NULL COMMENT '注销时的生日', + gender TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=男 2=女', + status TINYINT NOT NULL DEFAULT 1 COMMENT '注销前账号状态:1=正常 0=禁用', + email VARCHAR(255) NOT NULL DEFAULT '' COMMENT '注销时绑定的邮箱,注销后可重新注册', + last_login_at DATETIME(3) NULL COMMENT '注销前最近登录时间', + last_active_at DATETIME(3) NULL COMMENT '注销前最近活跃时间', + created_at DATETIME(3) NOT NULL COMMENT '原注册时间', + updated_at DATETIME(3) NOT NULL COMMENT '原资料更新时间', + deleted_at DATETIME(3) NOT NULL COMMENT '注销时间', + KEY idx_email (email), + KEY idx_spark_id (spark_id), + KEY idx_deleted_at (deleted_at) +) COMMENT='已注销用户档案。从 users 迁入后删除原行,邮箱与 spark_id 可重新注册';