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`.
|
- New goroutine I/O does not belong in `OnTraffic`.
|
||||||
- HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context.
|
- HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context.
|
||||||
- Closed type/status: entity typed enum + `Valid()` + OpenAPI named `$ref` + validator `oneof` on the same values ([enums.md](enums.md)).
|
- Closed type/status: entity typed enum + `Valid()` + OpenAPI named `$ref` + validator `oneof` on the same values ([enums.md](enums.md)).
|
||||||
- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`).
|
- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`). Logout is `POST /v1/auth/logout` (current device). Account cancellation is `DELETE /v1/users/me` (archive to `user_delete`, purge friends/moments/related).
|
||||||
- SQL tables and GORM models carry COMMENT on the table and every column.
|
- SQL tables and GORM models carry COMMENT on the table and every column.
|
||||||
- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy.
|
- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy.
|
||||||
- Run `make lint` (funlen 100).
|
- Run `make lint` (funlen 100).
|
||||||
|
|||||||
@ -80,7 +80,7 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
|
|||||||
|-------|--------|----------|
|
|-------|--------|----------|
|
||||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) |
|
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) |
|
||||||
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
||||||
| users | `/v1/users` | `GET/PATCH /v1/users/me` |
|
| users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` |
|
||||||
| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report |
|
| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report |
|
||||||
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
||||||
| conversations | `/v1/conversations` | list, mute |
|
| conversations | `/v1/conversations` | list, mute |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,3 +10,4 @@ vendor/
|
|||||||
coverage.out
|
coverage.out
|
||||||
.env
|
.env
|
||||||
certs/*.key
|
certs/*.key
|
||||||
|
server
|
||||||
|
|||||||
@ -120,8 +120,8 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
|
|||||||
| 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 |
|
| 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 |
|
||||||
| 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 |
|
| 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 |
|
||||||
| 枚举 | `GET /v1/meta/enums` | 无需登录 |
|
| 枚举 | `GET /v1/meta/enums` | 无需登录 |
|
||||||
| 认证 | `/v1/auth` | 发码、登录、刷新、登出 |
|
| 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 |
|
||||||
| 用户 | `/v1/users/me` | 资料 |
|
| 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) |
|
||||||
| 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 |
|
| 好友 | `/v1/friends` | 申请、同意、备注、置顶、拉黑、举报 |
|
||||||
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
|
| 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 |
|
||||||
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
|
| 文件 | `/v1/files` | 上传/下载凭证、批量、图片派生 |
|
||||||
|
|||||||
@ -172,6 +172,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
|||||||
files := wireFiles(cfg, objects, groups)
|
files := wireFiles(cfg, objects, groups)
|
||||||
favorites := wireFavorites(idgen, db, objects, chat)
|
favorites := wireFavorites(idgen, db, objects, chat)
|
||||||
moments := wireMoments(idgen, db, objects, friends, users)
|
moments := wireMoments(idgen, db, objects, friends, users)
|
||||||
|
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...)
|
||||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||||
Addr: cfg.Server.TCP.Addr,
|
Addr: cfg.Server.TCP.Addr,
|
||||||
Multicore: cfg.Server.TCP.Multicore,
|
Multicore: cfg.Server.TCP.Multicore,
|
||||||
@ -366,6 +367,45 @@ func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *gro
|
|||||||
return msglogic.NewService(chat.msgs, chat.chats, chat.p2p, friends, groups, idgen, q)
|
return msglogic.NewService(chat.msgs, chat.chats, chat.p2p, friends, groups, idgen, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func accountCleaners(
|
||||||
|
friends *friendlogic.Service,
|
||||||
|
moments *momentlogic.Service,
|
||||||
|
groups *grouplogic.Service,
|
||||||
|
favorites *favlogic.Service,
|
||||||
|
inbox inboxlogic.Store,
|
||||||
|
mailbox synclogic.Mailbox,
|
||||||
|
chat chatStores,
|
||||||
|
) []userlogic.UserDataCleaner {
|
||||||
|
out := make([]userlogic.UserDataCleaner, 0, 8)
|
||||||
|
if friends != nil {
|
||||||
|
out = append(out, friends)
|
||||||
|
}
|
||||||
|
if moments != nil {
|
||||||
|
out = append(out, moments)
|
||||||
|
}
|
||||||
|
if groups != nil {
|
||||||
|
out = append(out, groups)
|
||||||
|
}
|
||||||
|
if favorites != nil {
|
||||||
|
out = append(out, favorites)
|
||||||
|
}
|
||||||
|
if inbox != nil {
|
||||||
|
out = append(out, userlogic.CleanerFunc(inbox.PurgeUser))
|
||||||
|
}
|
||||||
|
if mailbox != nil {
|
||||||
|
out = append(out, userlogic.CleanerFunc(mailbox.PurgeUser))
|
||||||
|
}
|
||||||
|
if chat.msgs != nil {
|
||||||
|
out = append(out, userlogic.CleanerFunc(chat.msgs.PurgeUser))
|
||||||
|
}
|
||||||
|
if p, ok := chat.chats.(interface {
|
||||||
|
PurgeUser(context.Context, int64) error
|
||||||
|
}); ok {
|
||||||
|
out = append(out, userlogic.CleanerFunc(p.PurgeUser))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
|
func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
|
||||||
if rdb != nil && rdb.Ready() {
|
if rdb != nil && rdb.Ready() {
|
||||||
return authlogic.NewRedisDenylist(rdb.RDB(), prefix)
|
return authlogic.NewRedisDenylist(rdb.RDB(), prefix)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ type Issuer interface {
|
|||||||
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
|
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
|
||||||
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
|
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
|
||||||
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
|
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
|
||||||
|
LogoutAll(ctx context.Context, userID int64) error
|
||||||
Revoke(ctx context.Context, token string) error
|
Revoke(ctx context.Context, token string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,6 +232,29 @@ func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (j *JWT) LogoutAll(ctx context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
rows, err := j.pairs.ListByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
row := rows[i]
|
||||||
|
if row.Pair.Access != "" {
|
||||||
|
_ = j.denylist.Add(ctx, row.Pair.Access, j.ttl)
|
||||||
|
}
|
||||||
|
if row.Pair.Refresh != "" {
|
||||||
|
_ = j.denylist.Add(ctx, row.Pair.Refresh, j.refreshTTL)
|
||||||
|
}
|
||||||
|
if err := j.pairs.Delete(ctx, userID, row.DeviceID); err != nil {
|
||||||
|
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
|
func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
|
||||||
c, err := j.parse(token, false)
|
c, err := j.parse(token, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -143,6 +143,31 @@ func TestJWTLogoutRevokesPair(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
j := testJWT(t, nil)
|
||||||
|
first, err := j.Issue(ctx, 8, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := j.Issue(ctx, 8, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := j.LogoutAll(ctx, 8); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := j.Authenticate(ctx, first.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||||
|
t.Fatalf("first access: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := j.Authenticate(ctx, second.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||||
|
t.Fatalf("second access: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := j.Refresh(ctx, first.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||||
|
t.Fatalf("first refresh: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJWTDenylistRevoke(t *testing.T) {
|
func TestJWTDenylistRevoke(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -28,6 +29,13 @@ type PairStore interface {
|
|||||||
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
|
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
|
||||||
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
|
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
|
||||||
Delete(ctx context.Context, userID int64, deviceID uint64) error
|
Delete(ctx context.Context, userID int64, deviceID uint64) error
|
||||||
|
ListByUser(ctx context.Context, userID int64) ([]DevicePair, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevicePair is the live token pair for one device of a user.
|
||||||
|
type DevicePair struct {
|
||||||
|
DeviceID uint64
|
||||||
|
Pair TokenPair
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryPairs struct {
|
type MemoryPairs struct {
|
||||||
@ -79,6 +87,33 @@ func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
prefix := strconv.FormatInt(userID, 10) + ":"
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]DevicePair, 0)
|
||||||
|
for key := range s.items {
|
||||||
|
row := s.items[key]
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now >= row.exp {
|
||||||
|
delete(s.items, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
did, err := strconv.ParseUint(key[len(prefix):], 10, 64)
|
||||||
|
if err != nil || did == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, DevicePair{DeviceID: did, Pair: row.pair})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
type RedisPairs struct {
|
type RedisPairs struct {
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
prefix string
|
prefix string
|
||||||
@ -133,3 +168,44 @@ func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64)
|
|||||||
}
|
}
|
||||||
return s.rdb.Del(ctx, s.key(userID, deviceID)).Err()
|
return s.rdb.Del(ctx, s.key(userID, deviceID)).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair, error) {
|
||||||
|
if s == nil || s.rdb == nil {
|
||||||
|
return nil, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
prefix := s.prefix + strconv.FormatInt(userID, 10) + ":"
|
||||||
|
var cursor uint64
|
||||||
|
out := make([]DevicePair, 0)
|
||||||
|
for {
|
||||||
|
keys, next, err := s.rdb.Scan(ctx, cursor, prefix+"*", 100).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
did, err := strconv.ParseUint(strings.TrimPrefix(key, prefix), 10, 64)
|
||||||
|
if err != nil || did == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := s.rdb.Get(ctx, key).Bytes()
|
||||||
|
if err == redis.Nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var pair TokenPair
|
||||||
|
if err := json.Unmarshal(raw, &pair); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, DevicePair{DeviceID: did, Pair: pair})
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -125,3 +125,15 @@ func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) erro
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryP2P) PurgeUser(_ context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, byUser := range m.members {
|
||||||
|
delete(byUser, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -121,6 +121,17 @@ func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Repo) PurgeUser(ctx context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
db, err := m.session(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return mapDBErr(db.Where("user_id = ?", userID).Delete(&ConversationMember{}).Error)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Conversation) entity() *entity.Conversation {
|
func (r *Conversation) entity() *entity.Conversation {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
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
|
Create(ctx context.Context, fav *entity.Favorite) error
|
||||||
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error)
|
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error)
|
||||||
Delete(ctx context.Context, userID, id int64) error
|
Delete(ctx context.Context, userID, id int64) error
|
||||||
|
ListAllByUser(ctx context.Context, userID int64) ([]*entity.Favorite, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Messages interface {
|
type Messages interface {
|
||||||
|
|||||||
@ -80,6 +80,19 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) ListAllByUser(_ context.Context, userID int64) ([]*entity.Favorite, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]*entity.Favorite, 0)
|
||||||
|
for _, row := range m.byID {
|
||||||
|
if row.UserID == userID {
|
||||||
|
out = append(out, cloneFav(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error {
|
func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -98,6 +98,22 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Repo) ListAllByUser(ctx context.Context, userID int64) ([]*entity.Favorite, error) {
|
||||||
|
db, err := m.session(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []Favorite
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("id DESC").Limit(10000).Find(&rows).Error; err != nil {
|
||||||
|
return nil, mapDBErr(err)
|
||||||
|
}
|
||||||
|
out := make([]*entity.Favorite, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out = append(out, rows[i].Entity())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Repo) Delete(ctx context.Context, userID, id int64) error {
|
func (m *Repo) Delete(ctx context.Context, userID, id int64) error {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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
|
CreateReport(ctx context.Context, r *entity.Report) error
|
||||||
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
|
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
|
||||||
DeleteFriend(ctx context.Context, a, b int64) error
|
DeleteFriend(ctx context.Context, a, b int64) error
|
||||||
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserFinder resolves accounts by uid / email / spark_id / qrcode ticket.
|
// UserFinder resolves accounts by uid / email / spark_id / qrcode ticket.
|
||||||
|
|||||||
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)
|
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)
|
Get(ctx context.Context, id int64) (*entity.Group, error)
|
||||||
GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error)
|
GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error)
|
||||||
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error)
|
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error)
|
||||||
|
ListAllActiveByUser(ctx context.Context, userID int64) ([]*entity.Group, error)
|
||||||
UpdateProfile(ctx context.Context, g *entity.Group) error
|
UpdateProfile(ctx context.Context, g *entity.Group) error
|
||||||
GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error)
|
GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error)
|
||||||
ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error)
|
ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error)
|
||||||
|
|||||||
@ -112,6 +112,31 @@ func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*enti
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const purgeListCap = 10000
|
||||||
|
|
||||||
|
func (m *Repo) ListAllActiveByUser(ctx context.Context, userID int64) ([]*entity.Group, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
db, err := m.session(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []Group
|
||||||
|
err = db.Table("groups AS g").Select("g.*").
|
||||||
|
Joins("INNER JOIN group_members m ON m.group_id = g.id").
|
||||||
|
Where("m.user_id = ? AND g.status = ?", userID, entity.StatusActive).
|
||||||
|
Order("g.updated_at DESC").Limit(purgeListCap).Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapDBErr(err)
|
||||||
|
}
|
||||||
|
out := make([]*entity.Group, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out = append(out, rows[i].entity())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
|
func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
|
||||||
if g == nil || g.ID <= 0 {
|
if g == nil || g.ID <= 0 {
|
||||||
return errs.ErrInvalidArgument
|
return errs.ErrInvalidArgument
|
||||||
|
|||||||
@ -73,7 +73,7 @@ func (m *Repo) ListMembers(ctx context.Context, groupID int64, limit int) ([]*en
|
|||||||
}
|
}
|
||||||
limit = entity.ClampMemberLimit(limit)
|
limit = entity.ClampMemberLimit(limit)
|
||||||
var rows []GroupMember
|
var rows []GroupMember
|
||||||
err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC").Limit(limit).Find(&rows).Error
|
err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC, id ASC").Limit(limit).Find(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapDBErr(err)
|
return nil, mapDBErr(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,6 +73,25 @@ func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) ListAllActiveByUser(_ context.Context, userID int64) ([]*entity.Group, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]*entity.Group, 0)
|
||||||
|
for gid, byUser := range m.members {
|
||||||
|
if _, ok := byUser[userID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g, ok := m.groups[gid]
|
||||||
|
if !ok || g.Status != entity.StatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := *g
|
||||||
|
out = append(out, &cp)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAtMS > out[j].UpdatedAtMS })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) {
|
func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@ -105,7 +124,10 @@ func (m *MemoryRepo) ListMembers(_ context.Context, groupID int64, limit int) ([
|
|||||||
if out[i].Role != out[j].Role {
|
if out[i].Role != out[j].Role {
|
||||||
return out[i].Role < out[j].Role
|
return out[i].Role < out[j].Role
|
||||||
}
|
}
|
||||||
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 {
|
if len(out) > limit {
|
||||||
out = out[:limit]
|
out = out[:limit]
|
||||||
|
|||||||
@ -14,4 +14,5 @@ type Store interface {
|
|||||||
SetPreview(ctx context.Context, conversationID int64, preview []byte) error
|
SetPreview(ctx context.Context, conversationID int64, preview []byte) error
|
||||||
ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error)
|
ListUpdatedSince(ctx context.Context, userID, score int64, limit int) ([]entity.DialogHit, error)
|
||||||
LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error)
|
LoadDialogs(ctx context.Context, userID int64, hits []entity.DialogHit) ([]entity.Dialog, error)
|
||||||
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,6 +109,16 @@ func (s *MemoryStore) LoadDialogs(_ context.Context, userID int64, hits []entity
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) PurgeUser(_ context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.byUser, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Preview(conversationID int64) []byte {
|
func (s *MemoryStore) Preview(conversationID int64) []byte {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@ -128,3 +128,10 @@ func (s *RedisStore) LoadDialogs(ctx context.Context, userID int64, hits []entit
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RedisStore) PurgeUser(ctx context.Context, userID int64) error {
|
||||||
|
if s == nil || s.rdb == nil || userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.rdb.Del(ctx, unreadKey(userID), readKey(userID), countKey(userID)).Err()
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ type Repository interface {
|
|||||||
ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error)
|
ListByConversation(ctx context.Context, conversationID, viewerID, lastMsgID int64, limit int) ([]*entity.Message, error)
|
||||||
Recall(ctx context.Context, id int64) error
|
Recall(ctx context.Context, id int64) error
|
||||||
HideForUser(ctx context.Context, userID int64, msg *entity.Message) error
|
HideForUser(ctx context.Context, userID int64, msg *entity.Message) error
|
||||||
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversations interface {
|
type Conversations interface {
|
||||||
|
|||||||
@ -135,6 +135,20 @@ func (m *MemoryRepo) HideForUser(_ context.Context, userID int64, msg *entity.Me
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for key := range m.hidden {
|
||||||
|
if key.UserID == userID {
|
||||||
|
delete(m.hidden, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) All() []*entity.Message {
|
func (m *MemoryRepo) All() []*entity.Message {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -110,3 +110,14 @@ func (m *Repo) HideForUser(ctx context.Context, userID int64, msg *entity.Messag
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Repo) PurgeUser(ctx context.Context, userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
db, err := m.session(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return mapDBErr(db.Where("user_id = ?", userID).Delete(&MessageDelete{}).Error)
|
||||||
|
}
|
||||||
|
|||||||
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)
|
HideMeFromIDs(ctx context.Context, viewerID int64) ([]int64, error)
|
||||||
UpsertHide(ctx context.Context, h *entity.Hide) error
|
UpsertHide(ctx context.Context, h *entity.Hide) error
|
||||||
DeleteHide(ctx context.Context, userID, peerID int64) error
|
DeleteHide(ctx context.Context, userID, peerID int64) error
|
||||||
|
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error)
|
||||||
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Friends is the friend graph used for visibility. Friend module implements it.
|
// Friends is the friend graph used for visibility. Friend module implements it.
|
||||||
|
|||||||
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
|
Put(ctx context.Context, userID int64, eventType entity.EventType, conversationID int64, payload []byte) error
|
||||||
ListSince(ctx context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error)
|
ListSince(ctx context.Context, userID int64, deviceID uint64, seq int64, limit int) ([]*entity.MailboxItem, error)
|
||||||
SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error
|
SaveCursor(ctx context.Context, userID int64, deviceID uint64, mailboxSeq, dialogScore int64) error
|
||||||
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxResponse struct {
|
type InboxResponse struct {
|
||||||
|
|||||||
@ -94,3 +94,25 @@ func (s *MemoryStore) SaveCursor(_ context.Context, userID int64, deviceID uint6
|
|||||||
s.cursors[[2]int64{userID, int64(deviceID)}] = memCursor{MailboxSeq: mailboxSeq, DialogScore: dialogScore}
|
s.cursors[[2]int64{userID, int64(deviceID)}] = memCursor{MailboxSeq: mailboxSeq, DialogScore: dialogScore}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) PurgeUser(_ context.Context, userID int64) error {
|
||||||
|
if s == nil || userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.seq, userID)
|
||||||
|
kept := s.events[:0]
|
||||||
|
for i := range s.events {
|
||||||
|
if s.events[i].UserID != userID {
|
||||||
|
kept = append(kept, s.events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.events = kept
|
||||||
|
for key := range s.cursors {
|
||||||
|
if key[0] == userID {
|
||||||
|
delete(s.cursors, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -125,3 +125,17 @@ func (s *Store) SaveCursor(ctx context.Context, userID int64, deviceID uint64, m
|
|||||||
DoUpdates: clause.AssignmentColumns([]string{"mailbox_seq", "dialog_score", "updated_at"}),
|
DoUpdates: clause.AssignmentColumns([]string{"mailbox_seq", "dialog_score", "updated_at"}),
|
||||||
}).Create(&row).Error)
|
}).Create(&row).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) PurgeUser(ctx context.Context, userID int64) error {
|
||||||
|
if s == nil || userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := s.session(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Where("user_id = ?", userID).Delete(&MailboxEvent{}).Error; err != nil {
|
||||||
|
return mapDBErr(err)
|
||||||
|
}
|
||||||
|
return mapDBErr(db.Where("user_id = ?", userID).Delete(&DeviceSyncCursor{}).Error)
|
||||||
|
}
|
||||||
|
|||||||
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
|
UpsertDevice(ctx context.Context, d *entity.Device) error
|
||||||
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error)
|
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error)
|
||||||
InsertLoginLog(ctx context.Context, log *entity.LoginLog) error
|
InsertLoginLog(ctx context.Context, log *entity.LoginLog) error
|
||||||
|
GetEmail(ctx context.Context, userID int64) (string, error)
|
||||||
|
GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error)
|
||||||
|
ArchiveAndDelete(ctx context.Context, d *entity.Deleted) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDGen issues time-ordered Snowflake IDs (user PK).
|
// IDGen issues time-ordered Snowflake IDs (user PK).
|
||||||
@ -36,12 +39,19 @@ type Objects interface {
|
|||||||
|
|
||||||
// Service is the user application use-case (HTTP and TCP share it).
|
// Service is the user application use-case (HTTP and TCP share it).
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
codes CodeStore
|
codes CodeStore
|
||||||
ids IDGen
|
ids IDGen
|
||||||
objects Objects
|
objects Objects
|
||||||
|
tokens TokenRevoker
|
||||||
|
sessions SessionDropper
|
||||||
|
cleaners []UserDataCleaner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
|
func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
|
||||||
return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
|
return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ready() bool {
|
||||||
|
return s != nil && s.repo != nil && s.ids != nil
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type MemoryRepo struct {
|
|||||||
bySpark map[string]int64
|
bySpark map[string]int64
|
||||||
devices map[deviceKey]*entity.Device
|
devices map[deviceKey]*entity.Device
|
||||||
logs []*entity.LoginLog
|
logs []*entity.LoginLog
|
||||||
|
deleted map[int64]*entity.Deleted
|
||||||
deviceSeq int64
|
deviceSeq int64
|
||||||
logSeq int64
|
logSeq int64
|
||||||
}
|
}
|
||||||
@ -32,6 +33,7 @@ func NewMemoryRepo() *MemoryRepo {
|
|||||||
bySpark: make(map[string]int64),
|
bySpark: make(map[string]int64),
|
||||||
devices: make(map[deviceKey]*entity.Device),
|
devices: make(map[deviceKey]*entity.Device),
|
||||||
logs: make([]*entity.LoginLog, 0),
|
logs: make([]*entity.LoginLog, 0),
|
||||||
|
deleted: make(map[int64]*entity.Deleted),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +209,71 @@ func (m *MemoryRepo) InsertLoginLog(_ context.Context, log *entity.LoginLog) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) GetEmail(_ context.Context, userID int64) (string, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return "", errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for email, id := range m.byEmail {
|
||||||
|
if id == userID {
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errs.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) GetDeleted(_ context.Context, id int64) (*entity.Deleted, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
row, ok := m.deleted[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *row
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRepo) ArchiveAndDelete(_ context.Context, d *entity.Deleted) error {
|
||||||
|
if d == nil || d.ID <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
u, ok := m.byID[d.ID]
|
||||||
|
if !ok {
|
||||||
|
return errs.ErrNotFound
|
||||||
|
}
|
||||||
|
email := d.Email
|
||||||
|
for em, id := range m.byEmail {
|
||||||
|
if id == d.ID {
|
||||||
|
email = em
|
||||||
|
delete(m.byEmail, em)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.DeletedAt.IsZero() {
|
||||||
|
d.DeletedAt = time.Now()
|
||||||
|
}
|
||||||
|
snap := entity.SnapshotDeleted(u, email, d.DeletedAt)
|
||||||
|
m.deleted[d.ID] = snap
|
||||||
|
delete(m.byID, d.ID)
|
||||||
|
delete(m.bySpark, u.SparkID)
|
||||||
|
for key := range m.devices {
|
||||||
|
if key.UserID == d.ID {
|
||||||
|
delete(m.devices, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept := m.logs[:0]
|
||||||
|
for _, log := range m.logs {
|
||||||
|
if log != nil && log.UserID != d.ID {
|
||||||
|
kept = append(kept, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.logs = kept
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) LoginLogs() []*entity.LoginLog {
|
func (m *MemoryRepo) LoginLogs() []*entity.LoginLog {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
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: {
|
NotifyKickLogout: {
|
||||||
ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出",
|
ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出",
|
||||||
},
|
},
|
||||||
|
NotifyKickDeleted: {
|
||||||
|
ZhCN: "账号已注销", En: "Account deleted", ZhTW: "帳號已註銷",
|
||||||
|
},
|
||||||
NotifyFriendRequest + ".title": {
|
NotifyFriendRequest + ".title": {
|
||||||
ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求",
|
ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -148,6 +148,7 @@ const (
|
|||||||
|
|
||||||
NotifyKickReplaced = "notify.kick.replaced"
|
NotifyKickReplaced = "notify.kick.replaced"
|
||||||
NotifyKickLogout = "notify.kick.logout"
|
NotifyKickLogout = "notify.kick.logout"
|
||||||
|
NotifyKickDeleted = "notify.kick.deleted"
|
||||||
NotifyFriendRequest = "notify.friend.request"
|
NotifyFriendRequest = "notify.friend.request"
|
||||||
NotifyFriendBecame = "notify.friend.became"
|
NotifyFriendBecame = "notify.friend.became"
|
||||||
NotifyGroupInvite = "notify.group.invite"
|
NotifyGroupInvite = "notify.group.invite"
|
||||||
|
|||||||
@ -688,6 +688,21 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
|
delete:
|
||||||
|
tags: [users]
|
||||||
|
summary: 注销当前账号
|
||||||
|
description: |
|
||||||
|
吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。
|
||||||
|
同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。
|
||||||
|
对方仍可见历史消息(不删 `messages`)。
|
||||||
|
operationId: deleteMe
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 统一 envelope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/friends/lookup:
|
/v1/friends/lookup:
|
||||||
get:
|
get:
|
||||||
tags: [friends]
|
tags: [friends]
|
||||||
|
|||||||
@ -50,3 +50,16 @@ func (h *Handler) PatchMe(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
respond.JSON(c, userlogic.PublicProfile(u))
|
respond.JSON(c, userlogic.PublicProfile(u))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteMe(c *gin.Context) {
|
||||||
|
rc := reqctx.From(c.Request.Context())
|
||||||
|
if rc == nil || rc.UserID <= 0 {
|
||||||
|
respond.JSONError(c, errs.ErrUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.users.DeleteAccount(c.Request.Context(), rc.UserID); err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(c, gin.H{})
|
||||||
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
g := v1.Group("/users")
|
g := v1.Group("/users")
|
||||||
g.GET("/me", h.Me)
|
g.GET("/me", h.Me)
|
||||||
g.PATCH("/me", h.PatchMe)
|
g.PATCH("/me", h.PatchMe)
|
||||||
|
g.DELETE("/me", h.DeleteMe)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||||
|
|||||||
@ -151,6 +151,7 @@ func testLoginEngine(t *testing.T) *gin.Engine {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
||||||
|
users.BindAccountCleanup(j, nil)
|
||||||
return NewEngine(zap.NewNop(), &EngineDeps{
|
return NewEngine(zap.NewNop(), &EngineDeps{
|
||||||
Auth: j,
|
Auth: j,
|
||||||
Login: authlogic.NewLoginService(j, users),
|
Login: authlogic.NewLoginService(j, users),
|
||||||
@ -357,6 +358,68 @@ func TestRefreshAndLogout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteAccount(t *testing.T) {
|
||||||
|
r := testLoginEngine(t)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
|
||||||
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, loginReq)
|
||||||
|
var loginEnv reply.Envelope
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loginEnv.Code != "0" {
|
||||||
|
t.Fatalf("login %+v", loginEnv)
|
||||||
|
}
|
||||||
|
var tok authentity.IssuedToken
|
||||||
|
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
delReq := httptest.NewRequest(http.MethodDelete, "/v1/users/me", nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
|
r.ServeHTTP(w, delReq)
|
||||||
|
var delEnv reply.Envelope
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &delEnv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if delEnv.Code != "0" {
|
||||||
|
t.Fatalf("delete %+v", delEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
|
||||||
|
meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||||
|
r.ServeHTTP(w, meReq)
|
||||||
|
var meEnv reply.Envelope
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meEnv.Code != errs.ErrUnauthorized.Code {
|
||||||
|
t.Fatalf("after delete %+v", meEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
again := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
|
||||||
|
again.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, again)
|
||||||
|
var againEnv reply.Envelope
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &againEnv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if againEnv.Code != "0" {
|
||||||
|
t.Fatalf("re-register %+v", againEnv)
|
||||||
|
}
|
||||||
|
var next authentity.IssuedToken
|
||||||
|
if err := json.Unmarshal(againEnv.Data, &next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if next.UserID == tok.UserID {
|
||||||
|
t.Fatal("uid reused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginRejectsBadInput(t *testing.T) {
|
func TestLoginRejectsBadInput(t *testing.T) {
|
||||||
r := testLoginEngine(t)
|
r := testLoginEngine(t)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@ -69,3 +69,24 @@ func TestKickDevice(t *testing.T) {
|
|||||||
t.Fatal("expected empty")
|
t.Fatal("expected empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKickUser(t *testing.T) {
|
||||||
|
m := NewManager()
|
||||||
|
a := NewConn("s1", "10.0.0.1:1", 8)
|
||||||
|
a.Authenticate(3, 4)
|
||||||
|
b := NewConn("s2", "10.0.0.1:2", 8)
|
||||||
|
b.Authenticate(3, 5)
|
||||||
|
if err := m.Bind(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Bind(b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.KickUser(3)
|
||||||
|
if a.State() != StateClosed || b.State() != StateClosed {
|
||||||
|
t.Fatalf("state %d %d", a.State(), b.State())
|
||||||
|
}
|
||||||
|
if len(m.GetUserSessions(3)) != 0 {
|
||||||
|
t.Fatal("expected empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -53,3 +53,22 @@ func (m *memManager) KickDevice(userID int64, deviceID uint64) {
|
|||||||
delete(userMap, id)
|
delete(userMap, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memManager) KickUser(userID int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
userMap := m.byUser[userID]
|
||||||
|
if userMap == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for id, existing := range userMap {
|
||||||
|
reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickDeleted, nil)
|
||||||
|
_ = existing.Send(&Packet{
|
||||||
|
Command: protocol.CmdKick,
|
||||||
|
Body: protocol.EncodeKick(existing.RequestID(), reason),
|
||||||
|
})
|
||||||
|
_ = existing.Close()
|
||||||
|
delete(m.byID, id)
|
||||||
|
}
|
||||||
|
delete(m.byUser, userID)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type Manager interface {
|
|||||||
Get(sessionID string) (Session, bool)
|
Get(sessionID string) (Session, bool)
|
||||||
GetUserSessions(userID int64) []Session
|
GetUserSessions(userID int64) []Session
|
||||||
KickDevice(userID int64, deviceID uint64)
|
KickDevice(userID int64, deviceID uint64)
|
||||||
|
KickUser(userID int64)
|
||||||
SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error
|
SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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