feat: user delete account
This commit is contained in:
parent
f79211ddac
commit
1f9f1949ad
@ -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).
|
||||
|
||||
@ -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
1
.gitignore
vendored
@ -10,3 +10,4 @@ vendor/
|
||||
coverage.out
|
||||
.env
|
||||
certs/*.key
|
||||
server
|
||||
|
||||
@ -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` | 上传/下载凭证、批量、图片派生 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
28
internal/module/favorite/logic/purge.go
Normal file
28
internal/module/favorite/logic/purge.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
10
internal/module/friend/logic/purge.go
Normal file
10
internal/module/friend/logic/purge.go
Normal 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)
|
||||
}
|
||||
@ -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.
|
||||
|
||||
55
internal/module/friend/model/purge.go
Normal file
55
internal/module/friend/model/purge.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
73
internal/module/group/logic/purge.go
Normal file
73
internal/module/group/logic/purge.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
31
internal/module/moment/logic/purge.go
Normal file
31
internal/module/moment/logic/purge.go
Normal 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
|
||||
}
|
||||
@ -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.
|
||||
|
||||
92
internal/module/moment/model/memory_purge.go
Normal file
92
internal/module/moment/model/memory_purge.go
Normal 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
|
||||
}
|
||||
105
internal/module/moment/model/purge.go
Normal file
105
internal/module/moment/model/purge.go
Normal 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
34
internal/module/user/entity/deleted.go
Normal file
34
internal/module/user/entity/deleted.go
Normal 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,
|
||||
}
|
||||
}
|
||||
124
internal/module/user/logic/delete.go
Normal file
124
internal/module/user/logic/delete.go
Normal 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)
|
||||
}
|
||||
95
internal/module/user/logic/delete_test.go
Normal file
95
internal/module/user/logic/delete_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
143
internal/module/user/model/user_delete.go
Normal file
143
internal/module/user/model/user_delete.go
Normal 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
|
||||
}
|
||||
@ -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: "好友請求",
|
||||
},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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{})
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
21
migrations/0017_user_delete.sql
Normal file
21
migrations/0017_user_delete.sql
Normal 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 可重新注册';
|
||||
Loading…
Reference in New Issue
Block a user