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`.
- 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).

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

1
.gitignore vendored
View File

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

View File

@ -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` | 上传/下载凭证、批量、图片派生 |

View File

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

View File

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

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) {
ctx := context.Background()
j := testJWT(t, nil)

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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}
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"}),
}).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
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
}

View File

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

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: {
ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出",
},
NotifyKickDeleted: {
ZhCN: "账号已注销", En: "Account deleted", ZhTW: "帳號已註銷",
},
NotifyFriendRequest + ".title": {
ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求",
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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