feat: user delete account

This commit is contained in:
zhr 2026-09-20 17:49:09 +08:00
parent f79211ddac
commit 1f9f1949ad
52 changed files with 1462 additions and 10 deletions

View File

@ -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`. - 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. - 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)). - 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. - 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. - 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). - Run `make lint` (funlen 100).

View File

@ -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` 错误码) | | 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 /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 |
| 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 |

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ vendor/
coverage.out coverage.out
.env .env
certs/*.key certs/*.key
server

View File

@ -120,8 +120,8 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
| 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 | | 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 |
| 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 | | 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 |
| 枚举 | `GET /v1/meta/enums` | 无需登录 | | 枚举 | `GET /v1/meta/enums` | 无需登录 |
| 认证 | `/v1/auth` | 发码、登录、刷新、登出 | | 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 |
| 用户 | `/v1/users/me` | 资料 | | 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) |
| 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 | | 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 |
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 | | 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 | | 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |

View File

@ -172,6 +172,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
files := wireFiles(cfg, objects, groups) files := wireFiles(cfg, objects, groups)
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)...)
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,
@ -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) 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 { func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
if rdb != nil && rdb.Ready() { if rdb != nil && rdb.Ready() {
return authlogic.NewRedisDenylist(rdb.RDB(), prefix) return authlogic.NewRedisDenylist(rdb.RDB(), prefix)

View File

@ -24,6 +24,7 @@ type Issuer interface {
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
Refresh(ctx context.Context, refreshToken string) (*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 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 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 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) { func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
c, err := j.parse(token, false) c, err := j.parse(token, false)
if err != nil { if err != nil {

View File

@ -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) { func TestJWTDenylistRevoke(t *testing.T) {
ctx := context.Background() ctx := context.Background()
j := testJWT(t, nil) j := testJWT(t, nil)

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@ -28,6 +29,13 @@ type PairStore interface {
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
Delete(ctx context.Context, userID int64, deviceID uint64) 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 { type MemoryPairs struct {
@ -79,6 +87,33 @@ func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) e
return nil 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 { type RedisPairs struct {
rdb *redis.Client rdb *redis.Client
prefix string 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() 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
}

View File

@ -125,3 +125,15 @@ func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) erro
} }
return nil 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
}

View File

@ -121,6 +121,17 @@ func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error {
return nil 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 { func (r *Conversation) entity() *entity.Conversation {
if r == nil { if r == nil {
return nil return nil

View File

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

View File

@ -15,6 +15,7 @@ type Repository interface {
Create(ctx context.Context, fav *entity.Favorite) error Create(ctx context.Context, fav *entity.Favorite) error
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error)
Delete(ctx context.Context, userID, id int64) error Delete(ctx context.Context, userID, id int64) error
ListAllByUser(ctx context.Context, userID int64) ([]*entity.Favorite, error)
} }
type Messages interface { type Messages interface {

View File

@ -80,6 +80,19 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*
return out, nil 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 { func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -98,6 +98,22 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti
return out, nil 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 { func (m *Repo) Delete(ctx context.Context, userID, id int64) error {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {

View File

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

View File

@ -26,6 +26,7 @@ type Repository interface {
CreateReport(ctx context.Context, r *entity.Report) error CreateReport(ctx context.Context, r *entity.Report) error
AcceptFriend(ctx context.Context, in entity.AcceptParams) error AcceptFriend(ctx context.Context, in entity.AcceptParams) error
DeleteFriend(ctx context.Context, a, b int64) 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. // UserFinder resolves accounts by uid / email / spark_id / qrcode ticket.

View File

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

View File

@ -355,3 +355,79 @@ func TestGroupAdminAnnouncementRemarkMentions(t *testing.T) {
t.Fatalf("at outsider %v", err) 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)
}
}

View File

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

View File

@ -21,6 +21,7 @@ type Repository interface {
Get(ctx context.Context, id int64) (*entity.Group, error) Get(ctx context.Context, id int64) (*entity.Group, error)
GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error) GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error)
ListByUser(ctx context.Context, userID int64, limit int) ([]*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 UpdateProfile(ctx context.Context, g *entity.Group) error
GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error)
ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error) ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error)

View File

@ -112,6 +112,31 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti
return out, nil 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 { func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
if g == nil || g.ID <= 0 { if g == nil || g.ID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument

View File

@ -73,7 +73,7 @@ func (m *Repo) ListMembers(ctx context.Context, groupID int64, limit int) ([]*en
} }
limit = entity.ClampMemberLimit(limit) limit = entity.ClampMemberLimit(limit)
var rows []GroupMember 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 { if err != nil {
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }

View File

@ -73,6 +73,25 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*
return out, nil 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) { func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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 { if out[i].Role != out[j].Role {
return out[i].Role < out[j].Role return out[i].Role < out[j].Role
} }
if out[i].JoinedAtMS != out[j].JoinedAtMS {
return out[i].JoinedAtMS < out[j].JoinedAtMS return out[i].JoinedAtMS < out[j].JoinedAtMS
}
return out[i].UserID < out[j].UserID
}) })
if len(out) > limit { if len(out) > limit {
out = out[:limit] out = out[:limit]

View File

@ -14,4 +14,5 @@ type Store interface {
SetPreview(ctx context.Context, conversationID int64, preview []byte) error SetPreview(ctx context.Context, conversationID int64, preview []byte) error
ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, 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) LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error)
PurgeUser(ctx context.Context, userID int64) error
} }

View File

@ -109,6 +109,16 @@ func (s *MemoryStore) LoadDialogs(_ context.Context, userID int64, hits []entity
return out, nil 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 { func (s *MemoryStore) Preview(conversationID int64) []byte {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View File

@ -128,3 +128,10 @@ func (s *RedisStore) LoadDialogs(ctx context.Context, userID int64, hits []entit
} }
return out, nil 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()
}

View File

@ -23,6 +23,7 @@ type Repository interface {
ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error) ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error)
Recall(ctx context.Context, id int64) error Recall(ctx context.Context, id int64) error
HideForUser(ctx context.Context, userID int64, msg *entity.Message) error HideForUser(ctx context.Context, userID int64, msg *entity.Message) error
PurgeUser(ctx context.Context, userID int64) error
} }
type Conversations interface { type Conversations interface {

View File

@ -135,6 +135,20 @@ func (m *MemoryRepo) HideForUser(_ context.Context, userID int64, msg *entity.Me
return nil 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 { func (m *MemoryRepo) All() []*entity.Message {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -110,3 +110,14 @@ func (m *Repo) HideForUser(ctx context.Context, userID int64, msg *entity.Messag
} }
return nil 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)
}

View File

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

View File

@ -35,6 +35,8 @@ type Repository interface {
HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error) HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error)
UpsertHide(ctx context.Context, h *entity.Hide) error UpsertHide(ctx context.Context, h *entity.Hide) error
DeleteHide(ctx context.Context, userID, peerID int64) 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. // Friends is the friend graph used for visibility. Friend module implements it.

View File

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

View File

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

View File

@ -16,6 +16,7 @@ type Mailbox interface {
Put(ctx context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error 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) 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 SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error
PurgeUser(ctx context.Context, userID int64) error
} }
type InboxResponse struct { type InboxResponse struct {

View File

@ -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} s.cursors[[2]int64{userID, int64(deviceID)}] = memCursor{MailboxSeq: mailboxSeq, DialogScore: dialogScore}
return nil 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
}

View File

@ -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"}), DoUpdates: clause.AssignmentColumns([]string{"mailbox_seq", "dialog_score", "updated_at"}),
}).Create(&row).Error) }).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)
}

View File

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

View File

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

View File

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

View File

@ -22,6 +22,9 @@ type Repository interface {
UpsertDevice(ctx context.Context, d *entity.Device) error UpsertDevice(ctx context.Context, d *entity.Device) error
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error)
InsertLoginLog(ctx context.Context, log *entity.LoginLog) 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). // IDGen issues time-ordered Snowflake IDs (user PK).
@ -40,8 +43,15 @@ type Service struct {
codes CodeStore codes CodeStore
ids IDGen ids IDGen
objects Objects objects Objects
tokens TokenRevoker
sessions SessionDropper
cleaners []UserDataCleaner
} }
func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service { func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
return &Service{repo: repo, codes: codes, ids: ids, objects: objects} return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
} }
func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.ids != nil
}

View File

@ -16,6 +16,7 @@ type MemoryRepo struct {
bySpark map[string]int64 bySpark map[string]int64
devices map[deviceKey]*entity.Device devices map[deviceKey]*entity.Device
logs []*entity.LoginLog logs []*entity.LoginLog
deleted map[int64]*entity.Deleted
deviceSeq int64 deviceSeq int64
logSeq int64 logSeq int64
} }
@ -32,6 +33,7 @@ func NewMemoryRepo() *MemoryRepo {
bySpark: make(map[string]int64), bySpark: make(map[string]int64),
devices: make(map[deviceKey]*entity.Device), devices: make(map[deviceKey]*entity.Device),
logs: make([]*entity.LoginLog, 0), 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 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 { func (m *MemoryRepo) LoginLogs() []*entity.LoginLog {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

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

View File

@ -8,6 +8,9 @@ func init() {
NotifyKickLogout: { NotifyKickLogout: {
ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出", ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出",
}, },
NotifyKickDeleted: {
ZhCN: "账号已注销", En: "Account deleted", ZhTW: "帳號已註銷",
},
NotifyFriendRequest + ".title": { NotifyFriendRequest + ".title": {
ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求", ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求",
}, },

View File

@ -148,6 +148,7 @@ const (
NotifyKickReplaced = "notify.kick.replaced" NotifyKickReplaced = "notify.kick.replaced"
NotifyKickLogout = "notify.kick.logout" NotifyKickLogout = "notify.kick.logout"
NotifyKickDeleted = "notify.kick.deleted"
NotifyFriendRequest = "notify.friend.request" NotifyFriendRequest = "notify.friend.request"
NotifyFriendBecame = "notify.friend.became" NotifyFriendBecame = "notify.friend.became"
NotifyGroupInvite = "notify.group.invite" NotifyGroupInvite = "notify.group.invite"

View File

@ -688,6 +688,21 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $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: /v1/friends/lookup:
get: get:
tags: [friends] tags: [friends]

View File

@ -50,3 +50,16 @@ func (h *Handler) PatchMe(c *gin.Context) {
} }
respond.JSON(c, userlogic.PublicProfile(u)) 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{})
}

View File

@ -86,6 +86,7 @@ func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g := v1.Group("/users") g := v1.Group("/users")
g.GET("/me", h.Me) g.GET("/me", h.Me)
g.PATCH("/me", h.PatchMe) g.PATCH("/me", h.PatchMe)
g.DELETE("/me", h.DeleteMe)
} }
func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {

View File

@ -151,6 +151,7 @@ func testLoginEngine(t *testing.T) *gin.Engine {
t.Fatal(err) t.Fatal(err)
} }
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
users.BindAccountCleanup(j, nil)
return NewEngine(zap.NewNop(), &EngineDeps{ return NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Auth: j,
Login: authlogic.NewLoginService(j, users), 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) { func TestLoginRejectsBadInput(t *testing.T) {
r := testLoginEngine(t) r := testLoginEngine(t)
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@ -69,3 +69,24 @@ func TestKickDevice(t *testing.T) {
t.Fatal("expected empty") 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")
}
}

View File

@ -53,3 +53,22 @@ func (m *memManager) KickDevice(userID int64, deviceID uint64) {
delete(userMap, id) 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)
}

View File

@ -31,6 +31,7 @@ type Manager interface {
Get(sessionID string) (Session, bool) Get(sessionID string) (Session, bool)
GetUserSessions(userID int64) []Session GetUserSessions(userID int64) []Session
KickDevice(userID int64, deviceID uint64) KickDevice(userID int64, deviceID uint64)
KickUser(userID int64)
SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error
} }

View File

@ -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 可重新注册';