diff --git a/Makefile b/Makefile index 3072523..4f8ef5d 100644 --- a/Makefile +++ b/Makefile @@ -4,13 +4,14 @@ BIN := bin/spark-server LINUX_DIR := bin/linux_amd64 IMAGE ?= spark-server:latest LDFLAGS := -s -w -buildid= -LINT ?= golangci-lint +# Note: GNU Make predefines LINT=lint (lex); use := so golangci-lint wins over the builtin. +GOLANGCI_LINT := golangci-lint CONFIG ?= configs/config.yaml LOGIC_COVER_MIN ?= 65 .PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback -all: lint test build +all: test build build: $(GO) build -o $(BIN) ./cmd/server @@ -25,7 +26,8 @@ image: docker build --platform linux/amd64 -t $(IMAGE) . # All unit tests, then each module logic package must be >= LOGIC_COVER_MIN. -test: +# Requires lint to pass first. +test: lint $(GO) test ./... @echo "==> logic Service coverage (>= $(LOGIC_COVER_MIN)%)" @covout=$$($(GO) test -cover ./internal/module/*/logic); \ @@ -53,7 +55,7 @@ fmt: $(GO) fmt ./... lint: - $(LINT) run ./... + $(GOLANGCI_LINT) run ./... tidy: $(GO) mod tidy @@ -70,7 +72,7 @@ start: run: build ./$(BIN) -config $(CONFIG) -deploy: +deploy: test bash deploy/docker/deploy.sh deploy-rollback: diff --git a/internal/infrastructure/objectstore/ops.go b/internal/infrastructure/objectstore/ops.go index 1af6693..293e1e7 100644 --- a/internal/infrastructure/objectstore/ops.go +++ b/internal/infrastructure/objectstore/ops.go @@ -19,9 +19,9 @@ import ( ) const ( - maxGetBytes = 32 << 20 - maxPutBytes = 4 << 20 - maxPutLarge = 50 << 20 // admin music audio etc. + maxGetBytes = 32 << 20 + maxPutBytes = 4 << 20 + maxPutLarge = 50 << 20 // admin music audio etc. ) // PutLarge writes an object up to maxBytes (capped at 50 MiB). diff --git a/internal/module/admin/logic/complaints.go b/internal/module/admin/logic/complaints.go index e508f71..2380e79 100644 --- a/internal/module/admin/logic/complaints.go +++ b/internal/module/admin/logic/complaints.go @@ -64,28 +64,34 @@ func (s *Service) AddComplaint(ctx context.Context, email, target, reason, detai func complaintViews(rows []adminmodel.ComplaintRow) []ComplaintView { out := make([]ComplaintView, 0, len(rows)) - for _, r := range rows { - target := r.TargetMail + for i := range rows { + target := rows[i].TargetMail if target == "" { - target = strconv.FormatInt(r.TargetID, 10) + target = strconv.FormatInt(rows[i].TargetID, 10) } out = append(out, ComplaintView{ - ID: r.ID, Email: r.Email, Target: target, - Reason: reportReasonName(r.Reason), Detail: r.Detail, - Status: "open", CreatedAt: r.CreatedAt.Format(time.RFC3339), + ID: rows[i].ID, Email: rows[i].Email, Target: target, + Reason: reportReasonName(rows[i].Reason), Detail: rows[i].Detail, + Status: "open", CreatedAt: rows[i].CreatedAt.Format(time.RFC3339), }) } return out } +const ( + reportLabelSpam = "spam" + reportLabelHarassment = "harassment" + reportLabelFake = "fake" +) + func reportReasonName(r int) string { switch friendentity.ReportReason(r) { case friendentity.ReportSpam: - return "spam" + return reportLabelSpam case friendentity.ReportHarassment: - return "harassment" + return reportLabelHarassment case friendentity.ReportFake: - return "fake" + return reportLabelFake default: return "other" } @@ -93,11 +99,11 @@ func reportReasonName(r int) string { func parseReportReason(raw string) friendentity.ReportReason { switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "spam", "垃圾": + case "1", reportLabelSpam, "垃圾": return friendentity.ReportSpam - case "2", "harassment", "骚扰": + case "2", reportLabelHarassment, "骚扰": return friendentity.ReportHarassment - case "3", "fake", "不实身份": + case "3", reportLabelFake, "不实身份": return friendentity.ReportFake default: return friendentity.ReportOther diff --git a/internal/module/admin/logic/groups.go b/internal/module/admin/logic/groups.go index 34210d0..c597839 100644 --- a/internal/module/admin/logic/groups.go +++ b/internal/module/admin/logic/groups.go @@ -17,8 +17,8 @@ func (s *Service) ListGroups(ctx context.Context, q string) ([]GroupView, error) return nil, err } out := make([]GroupView, 0, len(rows)) - for _, r := range rows { - out = append(out, groupSummary(r)) + for i := range rows { + out = append(out, groupSummary(&rows[i])) } return out, nil } @@ -35,13 +35,14 @@ func (s *Service) GroupDetail(ctx context.Context, groupIDStr string) (*GroupVie if err != nil { return nil, err } - view := groupSummary(*g) + view := groupSummary(g) members, err := s.repo.ListGroupMembers(ctx, gid) if err != nil { return nil, err } view.Members = make([]GroupMemberOut, 0, len(members)) - for _, m := range members { + for i := range members { + m := &members[i] nick := m.Nickname if nick == "" { nick = m.UNick @@ -55,8 +56,8 @@ func (s *Service) GroupDetail(ctx context.Context, groupIDStr string) (*GroupVie return nil, err } view.Messages = make([]ImMessageView, 0, len(msgs)) - for _, m := range msgs { - view.Messages = append(view.Messages, messageView(m)) + for i := range msgs { + view.Messages = append(view.Messages, messageView(&msgs[i])) } return &view, nil } diff --git a/internal/module/admin/logic/im.go b/internal/module/admin/logic/im.go index 26b7a58..7647e3f 100644 --- a/internal/module/admin/logic/im.go +++ b/internal/module/admin/logic/im.go @@ -28,9 +28,9 @@ func (s *Service) ListMessages(ctx context.Context, q string, limit int) ([]ImMe return nil, err } out := make([]ImMessageView, 0, limit) - for _, r := range rows { - v := messageView(r) - if q != "" && !messageMatch(v, q) { + for i := range rows { + v := messageView(&rows[i]) + if q != "" && !messageMatch(&v, q) { continue } out = append(out, v) @@ -56,8 +56,9 @@ func (s *Service) ListConversations(ctx context.Context, userID int64) (convs [] convs = make([]ImConversationView, 0, len(rows)) groups = make([]GroupView, 0) seenGroup := map[int64]struct{}{} - for _, r := range rows { + for i := range rows { chatType := "private" + r := &rows[i] if r.Type == 2 { chatType = "group" } diff --git a/internal/module/admin/logic/map.go b/internal/module/admin/logic/map.go index 832f51b..bdefc6e 100644 --- a/internal/module/admin/logic/map.go +++ b/internal/module/admin/logic/map.go @@ -13,7 +13,13 @@ import ( userentity "spark-server/internal/module/user/entity" ) -func messageView(r adminmodel.MessageRow) adminentity.ImMessageView { +const ( + roleLabelOwner = "owner" + roleLabelAdmin = "admin" + roleLabelMember = "member" +) + +func messageView(r *adminmodel.MessageRow) adminentity.ImMessageView { return adminentity.ImMessageView{ ID: strconv.FormatInt(r.ID, 10), ClientMsgID: r.ClientMsgID, @@ -28,7 +34,7 @@ func messageView(r adminmodel.MessageRow) adminentity.ImMessageView { } } -func messageMatch(v adminentity.ImMessageView, q string) bool { +func messageMatch(v *adminentity.ImMessageView, q string) bool { ql := strings.ToLower(q) if strings.Contains(strings.ToLower(v.Text), ql) { return true @@ -117,7 +123,7 @@ func previewText(raw []byte, t msgentity.Type) string { return "" } -func userView(r adminmodel.UserRow) adminentity.UserView { +func userView(r *adminmodel.UserRow) adminentity.UserView { v := adminentity.UserView{ ID: r.ID, Email: r.Email, Nickname: r.Nickname, SparkID: r.SparkID, AvatarURL: r.Avatar, Channel: r.Platform, Status: r.Status, @@ -133,7 +139,7 @@ func userView(r adminmodel.UserRow) adminentity.UserView { return v } -func groupSummary(r adminmodel.GroupRow) adminentity.GroupView { +func groupSummary(r *adminmodel.GroupRow) adminentity.GroupView { return adminentity.GroupView{ GroupID: strconv.FormatInt(r.ID, 10), Name: r.Name, @@ -150,10 +156,10 @@ func groupSummary(r adminmodel.GroupRow) adminentity.GroupView { func roleName(role int) string { switch groupentity.Role(role) { case groupentity.RoleOwner: - return "owner" + return roleLabelOwner case groupentity.RoleAdmin: - return "admin" + return roleLabelAdmin default: - return "member" + return roleLabelMember } } diff --git a/internal/module/admin/logic/map_test.go b/internal/module/admin/logic/map_test.go index 0da1a24..6cbb8dd 100644 --- a/internal/module/admin/logic/map_test.go +++ b/internal/module/admin/logic/map_test.go @@ -33,14 +33,14 @@ func TestAdminMessageHelpers(t *testing.T) { if previewText([]byte("not-json"), msgentity.TypeText) != "" { t.Fatal("bad content") } - v := messageView(adminmodel.MessageRow{ + v := messageView(&adminmodel.MessageRow{ ID: 42, SenderID: 7, ReceiverID: 8, GroupID: 9, MessageType: int16(msgentity.TypeText), Content: mustMarshal(t, msgentity.NewText("needle")), Status: 2, CreatedAt: time.Unix(1, 0).UTC(), ChatType: "private", }) - if !v.Revoked || !messageMatch(v, "needle") || !messageMatch(v, "42") || !messageMatch(v, "text") || !messageMatch(v, "7") { + if !v.Revoked || !messageMatch(&v, "needle") || !messageMatch(&v, "42") || !messageMatch(&v, "text") || !messageMatch(&v, "7") { t.Fatalf("%+v", v) } - if messageMatch(v, "zzz") { + if messageMatch(&v, "zzz") { t.Fatal("no match") } } diff --git a/internal/module/admin/logic/service_test.go b/internal/module/admin/logic/service_test.go index 7b7db33..ff3c796 100644 --- a/internal/module/admin/logic/service_test.go +++ b/internal/module/admin/logic/service_test.go @@ -51,9 +51,9 @@ func (m *memoryAdmin) ListUsers(_ context.Context, emailLike string, page, size func (m *memoryAdmin) UsersByEmail(_ context.Context, email string) ([]adminmodel.UserRow, error) { out := make([]adminmodel.UserRow, 0) - for _, u := range m.users { - if u.Email == email { - out = append(out, u) + for i := range m.users { + if m.users[i].Email == email { + out = append(out, m.users[i]) } } return out, nil @@ -71,9 +71,9 @@ func (m *memoryAdmin) UserByID(_ context.Context, id int64) (*adminmodel.UserRow func (m *memoryAdmin) UsersByNickname(_ context.Context, nickname string) ([]adminmodel.UserRow, error) { out := make([]adminmodel.UserRow, 0) - for _, u := range m.users { - if u.Nickname == nickname { - out = append(out, u) + for i := range m.users { + if m.users[i].Nickname == nickname { + out = append(out, m.users[i]) } } return out, nil @@ -81,9 +81,9 @@ func (m *memoryAdmin) UsersByNickname(_ context.Context, nickname string) ([]adm func (m *memoryAdmin) UsersByDevice(_ context.Context, _ string, deviceIDNum uint64) ([]adminmodel.UserRow, error) { out := make([]adminmodel.UserRow, 0) - for _, u := range m.users { - if u.DeviceID == deviceIDNum { - out = append(out, u) + for i := range m.users { + if m.users[i].DeviceID == deviceIDNum { + out = append(out, m.users[i]) } } return out, nil diff --git a/internal/module/admin/logic/users.go b/internal/module/admin/logic/users.go index 98a5dc5..b57fcb6 100644 --- a/internal/module/admin/logic/users.go +++ b/internal/module/admin/logic/users.go @@ -24,8 +24,8 @@ func (s *Service) ListUsers(ctx context.Context, email string, page, size int) ( return nil, 0, err } list = make([]UserView, 0, len(rows)) - for _, r := range rows { - list = append(list, userView(r)) + for i := range rows { + list = append(list, userView(&rows[i])) } return list, total, nil } @@ -43,8 +43,8 @@ func (s *Service) UserByEmail(ctx context.Context, email string) (map[string]any return nil, err } items := make([]UserView, 0, len(rows)) - for _, r := range rows { - items = append(items, userView(r)) + for i := range rows { + items = append(items, userView(&rows[i])) } return map[string]any{ "email": email, "appUsers": items, "appCode": "spark", @@ -59,7 +59,7 @@ func (s *Service) UserByID(ctx context.Context, id int64) (*UserView, error) { if err != nil { return nil, err } - v := userView(*r) + v := userView(r) return &v, nil } @@ -72,8 +72,8 @@ func (s *Service) UsersByNickname(ctx context.Context, nickname string) ([]UserV return nil, err } out := make([]UserView, 0, len(rows)) - for _, r := range rows { - out = append(out, userView(r)) + for i := range rows { + out = append(out, userView(&rows[i])) } return out, nil } @@ -89,8 +89,8 @@ func (s *Service) UsersByDevice(ctx context.Context, deviceID string) ([]UserVie return nil, err } out := make([]UserView, 0, len(rows)) - for _, r := range rows { - out = append(out, userView(r)) + for i := range rows { + out = append(out, userView(&rows[i])) } return out, nil } @@ -104,8 +104,8 @@ func (s *Service) UsersByIP(ctx context.Context, ip string) ([]UserView, error) return nil, err } out := make([]UserView, 0, len(rows)) - for _, r := range rows { - out = append(out, userView(r)) + for i := range rows { + out = append(out, userView(&rows[i])) } return out, nil } @@ -162,9 +162,9 @@ func (s *Service) SameIPRank(ctx context.Context, limit int) ([]SameIPItem, erro return nil, err } out := make([]SameIPItem, 0, len(rows)) - for _, r := range rows { + for i := range rows { out = append(out, SameIPItem{ - IP: r.IP, UserCount: r.UserCount, DeviceCount: r.DeviceCount, Count: r.UserCount, + IP: rows[i].IP, UserCount: rows[i].UserCount, DeviceCount: rows[i].DeviceCount, Count: rows[i].UserCount, }) } return out, nil diff --git a/internal/module/admin/model/complaints.go b/internal/module/admin/model/complaints.go index 45fd6a0..a0860aa 100644 --- a/internal/module/admin/model/complaints.go +++ b/internal/module/admin/model/complaints.go @@ -36,11 +36,8 @@ LIMIT ?`, limit).Scan(&rows).Error return nil, mapDBErr(err) } out := make([]ComplaintRow, 0, len(rows)) - for _, r := range rows { - out = append(out, ComplaintRow{ - ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, Reason: r.Reason, - Detail: r.Detail, CreatedAt: r.CreatedAt, Email: r.Email, TargetMail: r.TargetMail, - }) + for i := range rows { + out = append(out, ComplaintRow(rows[i])) } return out, nil } diff --git a/internal/module/admin/model/groups.go b/internal/module/admin/model/groups.go index 9eebe01..ee759d8 100644 --- a/internal/module/admin/model/groups.go +++ b/internal/module/admin/model/groups.go @@ -23,12 +23,8 @@ type groupScanRow struct { UpdatedAt time.Time `gorm:"column:updated_at"` } -func groupScanToRow(r groupScanRow) GroupRow { - return GroupRow{ - ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, - OwnerID: r.OwnerID, MutedAll: r.MutedAll, MemberCount: r.MemberCount, Status: r.Status, - CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, - } +func groupScanToRow(r *groupScanRow) GroupRow { + return GroupRow(*r) } func (m *Repo) ListGroups(ctx context.Context, q string) ([]GroupRow, error) { @@ -51,8 +47,8 @@ func (m *Repo) ListGroups(ctx context.Context, q string) ([]GroupRow, error) { return nil, mapDBErr(err) } out := make([]GroupRow, 0, len(rows)) - for _, r := range rows { - out = append(out, groupScanToRow(r)) + for i := range rows { + out = append(out, groupScanToRow(&rows[i])) } return out, nil } @@ -66,7 +62,7 @@ func (m *Repo) GroupByID(ctx context.Context, groupID int64) (*GroupRow, error) if err := db.Table("`t_groups`").Where("id = ?", groupID).Take(&r).Error; err != nil { return nil, mapDBErr(err) } - row := groupScanToRow(r) + row := groupScanToRow(&r) return &row, nil } @@ -115,10 +111,8 @@ LIMIT 500`, groupID).Scan(&rows).Error return nil, mapDBErr(err) } out := make([]GroupMemberRow, 0, len(rows)) - for _, r := range rows { - out = append(out, GroupMemberRow{ - UserID: r.UserID, Role: r.Role, Nickname: r.Nickname, Email: r.Email, UNick: r.UNick, - }) + for i := range rows { + out = append(out, GroupMemberRow(rows[i])) } return out, nil } diff --git a/internal/module/admin/model/im.go b/internal/module/admin/model/im.go index b45d17f..4e2be26 100644 --- a/internal/module/admin/model/im.go +++ b/internal/module/admin/model/im.go @@ -56,13 +56,8 @@ FROM t_group_message` return nil, mapDBErr(err) } out := make([]MessageRow, 0, len(rows)) - for _, r := range rows { - out = append(out, MessageRow{ - ID: r.ID, ConversationID: r.ConversationID, SenderID: r.SenderID, - ReceiverID: r.ReceiverID, GroupID: r.GroupID, ClientMsgID: r.ClientMsgID, - MessageType: r.MessageType, Content: r.Content, Status: r.Status, - CreatedAt: r.CreatedAt, ChatType: r.ChatType, - }) + for i := range rows { + out = append(out, MessageRow(rows[i])) } return out, nil } @@ -98,11 +93,8 @@ LIMIT 200`, userID).Scan(&rows).Error return nil, mapDBErr(err) } out := make([]ConversationRow, 0, len(rows)) - for _, r := range rows { - out = append(out, ConversationRow{ - ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt, - UserSmall: r.UserSmall, UserBig: r.UserBig, GroupID: r.GroupID, GroupName: r.GroupName, - }) + for i := range rows { + out = append(out, ConversationRow(rows[i])) } return out, nil } @@ -116,6 +108,7 @@ func (m *Repo) ListGroupMessages(ctx context.Context, groupID int64, limit int) ID int64 `gorm:"column:id"` ConversationID int64 `gorm:"column:conversation_id"` SenderID int64 `gorm:"column:sender_id"` + ReceiverID int64 `gorm:"column:receiver_id"` GroupID int64 `gorm:"column:group_id"` ClientMsgID string `gorm:"column:client_msg_id"` MessageType int16 `gorm:"column:message_type"` @@ -126,18 +119,14 @@ func (m *Repo) ListGroupMessages(ctx context.Context, groupID int64, limit int) } var rows []scanRow err = db.Raw(` -SELECT id, conversation_id, sender_id, group_id, client_msg_id, message_type, content, status, created_at, 'group' AS chat_type +SELECT id, conversation_id, sender_id, 0 AS receiver_id, group_id, client_msg_id, message_type, content, status, created_at, 'group' AS chat_type FROM t_group_message WHERE group_id = ? ORDER BY created_at DESC LIMIT ?`, groupID, limit).Scan(&rows).Error if err != nil { return nil, mapDBErr(err) } out := make([]MessageRow, 0, len(rows)) - for _, r := range rows { - out = append(out, MessageRow{ - ID: r.ID, ConversationID: r.ConversationID, SenderID: r.SenderID, GroupID: r.GroupID, - ClientMsgID: r.ClientMsgID, MessageType: r.MessageType, Content: r.Content, - Status: r.Status, CreatedAt: r.CreatedAt, ChatType: r.ChatType, - }) + for i := range rows { + out = append(out, MessageRow(rows[i])) } return out, nil } diff --git a/internal/module/admin/model/users.go b/internal/module/admin/model/users.go index d4b61fa..da20a83 100644 --- a/internal/module/admin/model/users.go +++ b/internal/module/admin/model/users.go @@ -73,7 +73,7 @@ WHERE u.id = ?`, id).Scan(&rows).Error if len(rows) == 0 || rows[0].ID == 0 { return nil, errs.ErrNotFound } - r := userScanToRow(rows[0]) + r := userScanToRow(&rows[0]) return &r, nil } @@ -223,8 +223,8 @@ LIMIT ?`, limit).Scan(&rows).Error return nil, mapDBErr(err) } out := make([]SameIPRankRow, len(rows)) - for i, r := range rows { - out[i] = SameIPRankRow{IP: r.IP, UserCount: r.UserCount, DeviceCount: r.DeviceCount} + for i := range rows { + out[i] = SameIPRankRow{IP: rows[i].IP, UserCount: rows[i].UserCount, DeviceCount: rows[i].DeviceCount} } return out, nil } @@ -254,16 +254,12 @@ type userScanRow struct { func userScanToRows(rows []userScanRow) []UserRow { out := make([]UserRow, 0, len(rows)) - for _, r := range rows { - out = append(out, userScanToRow(r)) + for i := range rows { + out = append(out, userScanToRow(&rows[i])) } return out } -func userScanToRow(r userScanRow) UserRow { - return UserRow{ - ID: r.ID, SparkID: r.SparkID, Nickname: r.Nickname, Avatar: r.Avatar, - Status: r.Status, LastActiveAt: r.LastActiveAt, CreatedAt: r.CreatedAt, - Email: r.Email, DeviceID: r.DeviceID, Platform: r.Platform, - } +func userScanToRow(r *userScanRow) UserRow { + return UserRow(*r) } diff --git a/internal/module/message/model/repo.go b/internal/module/message/model/repo.go index b665422..2b62296 100644 --- a/internal/module/message/model/repo.go +++ b/internal/module/message/model/repo.go @@ -99,7 +99,7 @@ func (m *Repo) Create(ctx context.Context, message *entity.Message) error { message.Kind = kind row := rowFrom(message) stampCreatedAt(message, &row) - return m.insertBody(db, message, row) + return m.insertBody(db, message, &row) } func (m *Repo) GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) { @@ -151,7 +151,7 @@ func (m *Repo) CreateAndBump(ctx context.Context, message *entity.Message) error return err } message.Kind = kind - if err := m.insertBody(tx, message, row); err != nil { + if err := m.insertBody(tx, message, &row); err != nil { return err } res := tx.Table("t_conversations").Where("id = ?", message.ConversationID).Updates(map[string]any{ @@ -177,7 +177,7 @@ func stampCreatedAt(message *entity.Message, row *MessageRow) { message.CreatedAtMS = row.CreatedAt.UnixMilli() } -func (m *Repo) insertBody(db *gorm.DB, message *entity.Message, row MessageRow) error { +func (m *Repo) insertBody(db *gorm.DB, message *entity.Message, row *MessageRow) error { if message.Kind == entity.KindGroup { if err := m.fillGroupID(db, message); err != nil { return err diff --git a/internal/module/message/model/row.go b/internal/module/message/model/row.go index 15a3fb7..1f6bbdf 100644 --- a/internal/module/message/model/row.go +++ b/internal/module/message/model/row.go @@ -55,12 +55,12 @@ func rowFrom(m *entity.Message) MessageRow { } } -func privateRow(m *entity.Message, base MessageRow) PrivateMessage { - return PrivateMessage{MessageRow: base, ReceiverID: m.ReceiverID} +func privateRow(m *entity.Message, base *MessageRow) PrivateMessage { + return PrivateMessage{MessageRow: *base, ReceiverID: m.ReceiverID} } -func groupRow(m *entity.Message, base MessageRow) GroupMessage { - return GroupMessage{MessageRow: base, GroupID: m.GroupID} +func groupRow(m *entity.Message, base *MessageRow) GroupMessage { + return GroupMessage{MessageRow: *base, GroupID: m.GroupID} } func tableOf(kind entity.Kind) string { diff --git a/internal/module/meta/logic/enums_sms.go b/internal/module/meta/logic/enums_sms.go index 84aab9d..bcd739c 100644 --- a/internal/module/meta/logic/enums_sms.go +++ b/internal/module/meta/logic/enums_sms.go @@ -1,8 +1,8 @@ package logic import ( - smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/meta/entity" + smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/pkg/i18n" ) diff --git a/internal/module/music/logic/service.go b/internal/module/music/logic/service.go index c4b8d48..ee786ac 100644 --- a/internal/module/music/logic/service.go +++ b/internal/module/music/logic/service.go @@ -15,6 +15,21 @@ import ( var ErrNotReady = errors.New("music: service not ready") +const ( + mimeJPEG = "image/jpeg" + mimePNG = "image/png" + mimeWebP = "image/webp" + mimeGIF = "image/gif" + mimeMPEG = "audio/mpeg" + + extJPG = "jpg" + extJPEG = "jpeg" + extPNG = "png" + extWebP = "webp" + extGIF = "gif" + extMP3 = "mp3" +) + type Repository interface { ListAll(ctx context.Context) ([]entity.Track, error) ListEnabled(ctx context.Context) ([]entity.Track, error) @@ -259,7 +274,7 @@ func (s *Service) uploadImage(ctx context.Context, id int64, filename string, bo if err != nil { return nil, errs.ErrNotFound.WithMsg("曲目不存在") } - ext := extFor(ct, filename, "jpg") + ext := extFor(ct, filename, extJPG) key := fmt.Sprintf("perm/music/%s/%d/%d.%s", kind, id, time.Now().UnixNano(), ext) if err := s.put(ctx, key, body, ct, maxBytes); err != nil { return nil, err @@ -301,7 +316,7 @@ func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, bo if err != nil { return nil, errs.ErrNotFound.WithMsg("曲目不存在") } - ext := extFor(ct, filename, "mp3") + ext := extFor(ct, filename, extMP3) key := fmt.Sprintf("perm/music/audio/%d/%d.%s", id, time.Now().UnixNano(), ext) if err := s.put(ctx, key, body, ct, entity.MaxAudioBytes); err != nil { return nil, err @@ -393,11 +408,12 @@ func (s *Service) UpdateCategory(ctx context.Context, id int64, in CategoryInput return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空") } if in.ParentID != nil { - if *in.ParentID == 0 { + switch { + case *in.ParentID == 0: c.ParentID = nil - } else if *in.ParentID == id { + case *in.ParentID == id: return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级") - } else { + default: parent, err := s.repo.GetCategory(ctx, *in.ParentID) if err != nil { return nil, errs.ErrNotFound.WithMsg("父分类不存在") @@ -458,11 +474,10 @@ func (s *Service) applyTrackCategories(ctx context.Context, trackID int64, categ func buildCategoryTree(flat []entity.Category) []entity.Category { nodes := make(map[int64]*entity.Category, len(flat)) order := make([]int64, 0, len(flat)) - for _, c := range flat { - cc := c - cc.Children = nil - nodes[cc.ID] = &cc - order = append(order, cc.ID) + for i := range flat { + flat[i].Children = nil + nodes[flat[i].ID] = &flat[i] + order = append(order, flat[i].ID) } rootPtrs := make([]*entity.Category, 0) for _, id := range order { @@ -501,11 +516,11 @@ func dedupeIDs(ids []int64) []int64 { return out } -func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error { +func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error { if s.objects == nil || !s.objects.Ready() { return errs.ErrAvailable.WithMsg("对象存储未就绪") } - return s.objects.PutLarge(ctx, key, body, contentType, max) + return s.objects.PutLarge(ctx, key, body, contentType, maxBytes) } func (s *Service) deleteObject(ctx context.Context, key string) error { @@ -524,16 +539,16 @@ func normalizeMIME(raw, filename string) string { } ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), ".")) switch ext { - case "jpg", "jpeg": - return "image/jpeg" - case "png": - return "image/png" - case "webp": - return "image/webp" - case "gif": - return "image/gif" - case "mp3": - return "audio/mpeg" + case extJPG, extJPEG: + return mimeJPEG + case extPNG: + return mimePNG + case extWebP: + return mimeWebP + case extGIF: + return mimeGIF + case extMP3: + return mimeMPEG case "m4a": return "audio/mp4" default: @@ -565,15 +580,15 @@ func extFor(ct, filename, fallback string) string { } switch ct { case "image/jpeg", "image/jpg": - return "jpg" - case "image/png": - return "png" - case "image/webp": - return "webp" - case "image/gif": - return "gif" + return extJPG + case mimePNG: + return extPNG + case mimeWebP: + return extWebP + case mimeGIF: + return extGIF case "audio/mpeg", "audio/mp3": - return "mp3" + return extMP3 default: return fallback } diff --git a/internal/module/music/logic/service_test.go b/internal/module/music/logic/service_test.go new file mode 100644 index 0000000..df9c2db --- /dev/null +++ b/internal/module/music/logic/service_test.go @@ -0,0 +1,374 @@ +package logic + +import ( + "context" + "errors" + "testing" + + "spark-server/internal/module/music/entity" + "spark-server/internal/pkg/errs" +) + +type stubMusicRepo struct { + tracks map[int64]*entity.Track + categories map[int64]*entity.Category + trackCategories map[int64][]int64 + nextID int64 + nextCatID int64 + preview int +} + +func (s *stubMusicRepo) ensureCats() { + if s.categories == nil { + s.categories = map[int64]*entity.Category{} + } +} + +func (s *stubMusicRepo) ListAll(_ context.Context) ([]entity.Track, error) { + out := make([]entity.Track, 0, len(s.tracks)) + for _, t := range s.tracks { + out = append(out, *t) + } + return out, nil +} + +func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) { + out := make([]entity.Track, 0) + for _, t := range s.tracks { + if t.Enabled { + out = append(out, *t) + } + } + return out, nil +} + +func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, _ int64) ([]entity.Track, error) { + return s.ListEnabled(context.Background()) +} + +func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) { + t, ok := s.tracks[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *t + if s.trackCategories != nil { + cp.CategoryIDs = append([]int64(nil), s.trackCategories[id]...) + } + return &cp, nil +} + +func (s *stubMusicRepo) Insert(_ context.Context, t *entity.Track) error { + if s.tracks == nil { + s.tracks = map[int64]*entity.Track{} + } + s.nextID++ + t.ID = s.nextID + cp := *t + s.tracks[t.ID] = &cp + return nil +} + +func (s *stubMusicRepo) Update(_ context.Context, t *entity.Track) error { + if s.tracks[t.ID] == nil { + return errs.ErrNotFound + } + cp := *t + s.tracks[t.ID] = &cp + return nil +} + +func (s *stubMusicRepo) Delete(_ context.Context, id int64) error { + if s.tracks[id] == nil { + return errs.ErrNotFound + } + delete(s.tracks, id) + return nil +} + +func (s *stubMusicRepo) MemberPreviewSeconds(context.Context) (int, error) { + if s.preview == 0 { + return entity.DefaultPreviewSeconds, nil + } + return s.preview, nil +} + +func (s *stubMusicRepo) SetSetting(context.Context, string, string) error { return nil } + +func (s *stubMusicRepo) ListCategories(_ context.Context) ([]entity.Category, error) { + s.ensureCats() + out := make([]entity.Category, 0, len(s.categories)) + for _, c := range s.categories { + out = append(out, *c) + } + return out, nil +} + +func (s *stubMusicRepo) ListEnabledCategories(ctx context.Context) ([]entity.Category, error) { + all, err := s.ListCategories(ctx) + if err != nil { + return nil, err + } + out := make([]entity.Category, 0) + for i := range all { + if all[i].Enabled { + out = append(out, all[i]) + } + } + return out, nil +} + +func (s *stubMusicRepo) GetCategory(_ context.Context, id int64) (*entity.Category, error) { + s.ensureCats() + c, ok := s.categories[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *c + return &cp, nil +} + +func (s *stubMusicRepo) InsertCategory(_ context.Context, c *entity.Category) error { + s.ensureCats() + s.nextCatID++ + c.ID = s.nextCatID + cp := *c + s.categories[c.ID] = &cp + return nil +} + +func (s *stubMusicRepo) UpdateCategory(_ context.Context, c *entity.Category) error { + s.ensureCats() + if s.categories[c.ID] == nil { + return errs.ErrNotFound + } + cp := *c + s.categories[c.ID] = &cp + return nil +} + +func (s *stubMusicRepo) DeleteCategory(_ context.Context, id int64) error { + s.ensureCats() + if s.categories[id] == nil { + return errs.ErrNotFound + } + delete(s.categories, id) + return nil +} + +func (s *stubMusicRepo) SetTrackCategories(_ context.Context, trackID int64, categoryIDs []int64) error { + if s.trackCategories == nil { + s.trackCategories = map[int64][]int64{} + } + s.trackCategories[trackID] = append([]int64(nil), categoryIDs...) + return nil +} + +type stubObjects struct { + ready bool +} + +func (o stubObjects) Ready() bool { return o.ready } + +func (o stubObjects) PutLarge(context.Context, string, []byte, string, int) error { return nil } + +func (o stubObjects) Delete(context.Context, string) error { return nil } + +func newTestService() (*Service, *stubMusicRepo) { + repo := &stubMusicRepo{preview: 30, tracks: map[int64]*entity.Track{}} + return NewService(repo, stubObjects{ready: true}, "https://cdn.example", "spark"), repo +} + +func TestServicePlaylistAndSettings(t *testing.T) { + svc, repo := newTestService() + _ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true}) + pl, err := svc.Playlist(context.Background(), 0) + if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 { + t.Fatalf("playlist %+v %v", pl, err) + } + st, err := svc.Settings(context.Background()) + if err != nil || st.MemberPreviewSeconds != 30 { + t.Fatalf("settings %+v %v", st, err) + } + all, err := svc.ListAdmin(context.Background()) + if err != nil || len(all) != 1 { + t.Fatalf("admin list %+v %v", all, err) + } +} + +func TestServiceCRUD(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + created, err := svc.Create(ctx, UpsertInput{Title: " hi "}) + if err != nil || created.ID != 1 || created.Title != "hi" { + t.Fatalf("create %+v %v", created, err) + } + req := true + updated, err := svc.Update(ctx, 1, UpsertInput{Title: "new", RequireMember: &req}) + if err != nil || !updated.RequireMember || updated.Title != "new" { + t.Fatalf("update %+v %v", updated, err) + } + if err := svc.Delete(ctx, 1); err != nil { + t.Fatal(err) + } + if err := svc.Delete(ctx, 1); err == nil { + t.Fatal("want not found") + } +} + +func TestServiceUpdateSettingsValidation(t *testing.T) { + svc, _ := newTestService() + _, err := svc.UpdateSettings(context.Background(), -1) + var ae *errs.AppError + if !errors.As(err, &ae) || ae.Code != errs.ErrInvalidArgument.Code { + t.Fatalf("want invalid %v", err) + } + ok, err := svc.UpdateSettings(context.Background(), 45) + if err != nil || ok.MemberPreviewSeconds != 45 { + t.Fatalf("settings %+v %v", ok, err) + } +} + +func TestServiceUploadValidation(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + if _, err := svc.Create(ctx, UpsertInput{Title: ""}); err == nil { + t.Fatal("empty title") + } + if _, err := svc.UploadCover(ctx, 1, "x.jpg", nil, ""); err == nil { + t.Fatal("missing track") + } + track, _ := svc.Create(ctx, UpsertInput{Title: "t"}) + if _, err := svc.UploadCover(ctx, track.ID, "x.jpg", nil, ""); err == nil { + t.Fatal("empty body") + } + if _, err := svc.UploadCover(ctx, track.ID, "x.bin", []byte("x"), "application/octet-stream"); err == nil { + t.Fatal("bad mime") + } + if _, err := svc.UploadAudio(ctx, track.ID, "a.mp3", []byte{1, 2}, mimeMPEG); err != nil { + t.Fatalf("audio %v", err) + } + got, err := svc.repo.Get(ctx, track.ID) + if err != nil || got.AudioKey == "" || got.AudioURL == "" { + t.Fatalf("track %+v %v", got, err) + } +} + +func TestNormalizeMIMEAndHelpers(t *testing.T) { + if got := normalizeMIME("", "photo.JPG"); got != mimeJPEG { + t.Fatalf("jpg %q", got) + } + if got := normalizeMIME("image/png", ""); got != mimePNG { + t.Fatalf("png %q", got) + } + if !isImageMIME(mimeGIF) || isImageMIME("text/plain") { + t.Fatal("image mime") + } + if !isAudioMIME(mimeMPEG, "a.mp3") || !isAudioMIME("", "song.mp3") { + t.Fatal("audio mime") + } + if extFor(mimePNG, "", "jpg") != "png" || extFor("", "file.weB", "bin") != "web" { + t.Fatal("extFor") + } + svc := NewService(&stubMusicRepo{}, nil, "https://cdn.example", "spark") + if u := svc.publicURL("perm/a.jpg"); u != "https://cdn.example/spark/perm/a.jpg" { + t.Fatalf("url %q", u) + } +} + +func TestServiceUploadBackground(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + track, err := svc.Create(ctx, UpsertInput{Title: "bg"}) + if err != nil { + t.Fatal(err) + } + body := []byte{0xff, 0xd8, 0xff} + updated, err := svc.UploadBackground(ctx, track.ID, "bg.jpg", body, mimeJPEG) + if err != nil || updated.BackgroundKey == "" || updated.BackgroundURL == "" { + t.Fatalf("background %+v %v", updated, err) + } +} + +func TestServicePutWithoutStorage(t *testing.T) { + repo := &stubMusicRepo{tracks: map[int64]*entity.Track{1: {ID: 1, Title: "x"}}} + svc := NewService(repo, stubObjects{ready: false}, "", "spark") + _, err := svc.UploadAudio(context.Background(), 1, "a.mp3", []byte{1}, mimeMPEG) + if err == nil { + t.Fatal("want storage error") + } +} + +func TestNewServicePublicURLDefaults(t *testing.T) { + svc := NewService(&stubMusicRepo{}, nil, "https://x", "") + if svc.publicURL("k") != "https://x/spark/k" { + t.Fatalf("%q", svc.publicURL("k")) + } +} + +func TestServiceNotReady(t *testing.T) { + var svc *Service + if _, err := svc.Playlist(context.Background(), 0); !errors.Is(err, ErrNotReady) { + t.Fatalf("%v", err) + } +} + +func TestServiceCategories(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + + root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Root"}) + if err != nil || root.ID != 1 { + t.Fatalf("root %+v %v", root, err) + } + pid := root.ID + child, err := svc.CreateCategory(ctx, CategoryInput{Name: "Child", ParentID: &pid}) + if err != nil || child.ParentID == nil || *child.ParentID != root.ID { + t.Fatalf("child %+v %v", child, err) + } + if _, err := svc.CreateCategory(ctx, CategoryInput{Name: "bad", ParentID: &child.ID}); err == nil { + t.Fatal("want depth limit") + } + tree, err := svc.CategoryTree(ctx, true) + if err != nil || len(tree) != 1 || len(tree[0].Children) != 1 { + t.Fatalf("tree %+v %v", tree, err) + } + if _, err := svc.ListCategoriesAdmin(ctx); err != nil { + t.Fatal(err) + } + dis := false + _, err = svc.UpdateCategory(ctx, root.ID, CategoryInput{ParentID: int64Ptr(0), Enabled: &dis}) + if err != nil { + t.Fatal(err) + } + track, err := svc.Create(ctx, UpsertInput{Title: "t", CategoryIDs: []int64{root.ID, root.ID, 0}}) + if err != nil || len(track.CategoryIDs) != 1 { + t.Fatalf("track cats %+v %v", track, err) + } + updated, err := svc.SetTrackCategories(ctx, track.ID, []int64{child.ID}) + if err != nil || len(updated.CategoryIDs) != 1 || updated.CategoryIDs[0] != child.ID { + t.Fatalf("set cats %+v %v", updated, err) + } + if err := svc.DeleteCategory(ctx, root.ID); err != nil { + t.Fatal(err) + } +} + +func int64Ptr(v int64) *int64 { return &v } + +func TestDedupeIDs(t *testing.T) { + got := dedupeIDs([]int64{1, 1, 0, -1, 2, 2}) + if len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Fatalf("%v", got) + } +} + +func TestBuildCategoryTreeOrphan(t *testing.T) { + tree := buildCategoryTree([]entity.Category{ + {ID: 2, Name: "orphan", ParentID: int64Ptr(99)}, + {ID: 1, Name: "root"}, + }) + if len(tree) != 2 { + t.Fatalf("%+v", tree) + } +} diff --git a/internal/module/music/model/repo.go b/internal/module/music/model/repo.go index 08c2a68..dc70622 100644 --- a/internal/module/music/model/repo.go +++ b/internal/module/music/model/repo.go @@ -82,8 +82,8 @@ func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) { return nil, err } out := make([]entity.Track, 0, len(rows)) - for _, row := range rows { - out = append(out, toEntity(row)) + for i := range rows { + out = append(out, toEntity(&rows[i])) } return r.attachCategoryIDs(ctx, out) } @@ -98,8 +98,8 @@ func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) { return nil, err } out := make([]entity.Track, 0, len(rows)) - for _, row := range rows { - out = append(out, toEntity(row)) + for i := range rows { + out = append(out, toEntity(&rows[i])) } return r.attachCategoryIDs(ctx, out) } @@ -131,8 +131,8 @@ func (r *Repo) ListEnabledByCategory(ctx context.Context, categoryID int64) ([]e return nil, err } out := make([]entity.Track, 0, len(rows)) - for _, row := range rows { - out = append(out, toEntity(row)) + for i := range rows { + out = append(out, toEntity(&rows[i])) } return r.attachCategoryIDs(ctx, out) } @@ -143,7 +143,8 @@ func (r *Repo) categoryScopeIDs(ctx context.Context, categoryID int64) ([]int64, return nil, err } byParent := map[int64][]int64{} - for _, c := range all { + for i := range all { + c := &all[i] if c.ParentID != nil { byParent[*c.ParentID] = append(byParent[*c.ParentID], c.ID) } @@ -170,7 +171,7 @@ func (r *Repo) Get(ctx context.Context, id int64) (*entity.Track, error) { if err != nil { return nil, err } - t := toEntity(row) + t := toEntity(&row) ids, err := r.TrackCategoryIDs(ctx, id) if err != nil { return nil, err @@ -292,8 +293,8 @@ func (r *Repo) ListCategories(ctx context.Context) ([]entity.Category, error) { return nil, err } out := make([]entity.Category, 0, len(rows)) - for _, row := range rows { - out = append(out, toCategoryEntity(row)) + for i := range rows { + out = append(out, toCategoryEntity(&rows[i])) } return out, nil } @@ -308,8 +309,8 @@ func (r *Repo) ListEnabledCategories(ctx context.Context) ([]entity.Category, er return nil, err } out := make([]entity.Category, 0, len(rows)) - for _, row := range rows { - out = append(out, toCategoryEntity(row)) + for i := range rows { + out = append(out, toCategoryEntity(&rows[i])) } return out, nil } @@ -323,7 +324,7 @@ func (r *Repo) GetCategory(ctx context.Context, id int64) (*entity.Category, err if err != nil { return nil, err } - c := toCategoryEntity(row) + c := toCategoryEntity(&row) return &c, nil } @@ -434,8 +435,8 @@ func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([] return tracks, nil } ids := make([]int64, 0, len(tracks)) - for _, t := range tracks { - ids = append(ids, t.ID) + for i := range tracks { + ids = append(ids, tracks[i].ID) } var links []categoryTrackRow err := r.db.WithContext(ctx).Where("track_id IN ?", ids).Order("category_id ASC").Find(&links).Error @@ -443,7 +444,8 @@ func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([] return nil, err } m := map[int64][]int64{} - for _, l := range links { + for i := range links { + l := &links[i] m[l.TrackID] = append(m[l.TrackID], l.CategoryID) } for i := range tracks { @@ -456,7 +458,7 @@ func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([] return tracks, nil } -func toEntity(row trackRow) entity.Track { +func toEntity(row *trackRow) entity.Track { return entity.Track{ ID: row.ID, Title: row.Title, @@ -475,7 +477,7 @@ func toEntity(row trackRow) entity.Track { } } -func toCategoryEntity(row categoryRow) entity.Category { +func toCategoryEntity(row *categoryRow) entity.Category { return entity.Category{ ID: row.ID, ParentID: row.ParentID, diff --git a/internal/module/seed/logic/group.go b/internal/module/seed/logic/group.go index aad0004..1f8c804 100644 --- a/internal/module/seed/logic/group.go +++ b/internal/module/seed/logic/group.go @@ -105,7 +105,8 @@ type sampleSpec struct { func (s *Service) ensureSampleMessages(ctx context.Context, g *groupentity.Group, bots []*userentity.User) (int, error) { specs := sampleSpecs() n := 0 - for _, spec := range specs { + for i := range specs { + spec := specs[i] senderID := int64(0) if spec.Type != msgentity.TypeSystem { senderID = bots[spec.Sender%len(bots)].ID diff --git a/internal/module/user/logic/delete_test.go b/internal/module/user/logic/delete_test.go index c1239f4..eae45b8 100644 --- a/internal/module/user/logic/delete_test.go +++ b/internal/module/user/logic/delete_test.go @@ -6,12 +6,12 @@ import ( "testing" "spark-server/internal/infrastructure/id" - smslogic "spark-server/internal/module/sms/logic" - smsmodel "spark-server/internal/module/sms/model" 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" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" "spark-server/internal/module/user/entity" "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go index c8506ae..ef946a7 100644 --- a/internal/module/user/logic/login_test.go +++ b/internal/module/user/logic/login_test.go @@ -11,9 +11,9 @@ import ( "testing" "spark-server/internal/infrastructure/id" + smsentity "spark-server/internal/module/sms/entity" smslogic "spark-server/internal/module/sms/logic" smsmodel "spark-server/internal/module/sms/model" - smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/user/entity" "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" diff --git a/internal/module/user/model/user_device.go b/internal/module/user/model/user_device.go index b3921b5..f28c847 100644 --- a/internal/module/user/model/user_device.go +++ b/internal/module/user/model/user_device.go @@ -10,12 +10,12 @@ import ( ) type UserDevice struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"` - UserID int64 `gorm:"column:user_id;comment:所属用户"` - DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"` - 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:该用户首次在此设备绑定"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"` + UserID int64 `gorm:"column:user_id;comment:所属用户"` + DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"` + 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:该用户首次在此设备绑定"` } func (UserDevice) TableName() string { return "t_user_devices" } diff --git a/internal/pkg/errs/codes.go b/internal/pkg/errs/codes.go index c28d8c6..54eab6b 100644 --- a/internal/pkg/errs/codes.go +++ b/internal/pkg/errs/codes.go @@ -2,27 +2,27 @@ package errs // Sentinel AppErrors. Code is the envelope code shared by HTTP and TCP. var ( - OK = NewError("0", "ok", "") - ErrInvalidArgument = NewError("40001", "invalid argument", "") - ErrFrameTooLarge = NewError("40002", "frame too large", "") - ErrInvalidFrame = NewError("40003", "invalid frame", "") - ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "") - ErrUnauthorized = NewError("40101", "unauthorized", "") - ErrNotAuthenticated = NewError("40102", "not authenticated", "") + OK = NewError("0", "ok", "") + ErrInvalidArgument = NewError("40001", "invalid argument", "") + ErrFrameTooLarge = NewError("40002", "frame too large", "") + ErrInvalidFrame = NewError("40003", "invalid frame", "") + ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "") + ErrUnauthorized = NewError("40101", "unauthorized", "") + ErrNotAuthenticated = NewError("40102", "not authenticated", "") ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "") - ErrForbidden = NewError("40301", "forbidden", "") - ErrNotFound = NewError("40401", "not found", "") - ErrMediaExpired = NewError("40402", "media expired", "") - ErrDuplicate = NewError("40901", "duplicate", "") - ErrBusy = NewError("42901", "busy", "") - ErrRateLimited = NewError("42902", "rate limited", "") - ErrOutboundDropped = NewError("42903", "outbound dropped", "") - ErrClosed = NewError("49901", "connection closed", "") - ErrInner = NewErrorPrint("50001", "internal", "") - ErrOperatFail = NewErrorPrint("50002", "operation failed", "") - ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "") - ErrAvailable = NewErrorPrint("50301", "unavailable", "") - ErrTimeout = NewErrorPrint("50401", "timeout", "") + ErrForbidden = NewError("40301", "forbidden", "") + ErrNotFound = NewError("40401", "not found", "") + ErrMediaExpired = NewError("40402", "media expired", "") + ErrDuplicate = NewError("40901", "duplicate", "") + ErrBusy = NewError("42901", "busy", "") + ErrRateLimited = NewError("42902", "rate limited", "") + ErrOutboundDropped = NewError("42903", "outbound dropped", "") + ErrClosed = NewError("49901", "connection closed", "") + ErrInner = NewErrorPrint("50001", "internal", "") + ErrOperatFail = NewErrorPrint("50002", "operation failed", "") + ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "") + ErrAvailable = NewErrorPrint("50301", "unavailable", "") + ErrTimeout = NewErrorPrint("50401", "timeout", "") ) // ErrInternal is an alias of ErrInner. diff --git a/internal/pkg/i18n/catalog_validate_sms.go b/internal/pkg/i18n/catalog_validate_sms.go index 98fa0fe..6c96646 100644 --- a/internal/pkg/i18n/catalog_validate_sms.go +++ b/internal/pkg/i18n/catalog_validate_sms.go @@ -31,7 +31,7 @@ func init() { }, KeySMSEmailBodyLogin: { ZhCN: "您的登录验证码是 {code},5 分钟内有效。如非本人操作请忽略。", - En: "Your login code is {code}. It expires in 5 minutes. Ignore this if you did not request it.", + En: "Your login code is {code}. It expires in 5 minutes. Ignore this if you did not request it.", ZhTW: "您的登入驗證碼是 {code},5 分鐘內有效。如非本人操作請忽略。", }, KeySMSEmailSubjectDelete: { @@ -39,7 +39,7 @@ func init() { }, KeySMSEmailBodyDelete: { ZhCN: "您的注销验证码是 {code},5 分钟内有效。提交后将永久删除账号。", - En: "Your account deletion code is {code}. It expires in 5 minutes. Submitting will permanently delete your account.", + En: "Your account deletion code is {code}. It expires in 5 minutes. Submitting will permanently delete your account.", ZhTW: "您的註銷驗證碼是 {code},5 分鐘內有效。提交後將永久刪除帳號。", }, "enum.verification_purpose.login": { diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index 7285358..30b2706 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -170,18 +170,18 @@ const ( KeySquareCommentInvalid = "validate.square.comment_invalid" KeySquareReplyInvalid = "validate.square.reply_invalid" - KeyFieldVerificationTarget = "field.verification_target" - KeySMSLanguageRequired = "validate.sms.language_required" - KeySMSTargetRequired = "validate.sms.target_required" - KeySMSTargetInvalid = "validate.sms.target_invalid" - KeyFieldVerificationPurpose = "field.verification_purpose" - KeySMSPurposeRequired = "validate.sms.purpose_required" - KeySMSPurposeInvalid = "validate.sms.purpose_invalid" + KeyFieldVerificationTarget = "field.verification_target" + KeySMSLanguageRequired = "validate.sms.language_required" + KeySMSTargetRequired = "validate.sms.target_required" + KeySMSTargetInvalid = "validate.sms.target_invalid" + KeyFieldVerificationPurpose = "field.verification_purpose" + KeySMSPurposeRequired = "validate.sms.purpose_required" + KeySMSPurposeInvalid = "validate.sms.purpose_invalid" KeySMSDeletePhoneUnsupported = "validate.sms.delete_phone_unsupported" - KeySMSEmailSubjectLogin = "sms.email.subject_login" - KeySMSEmailBodyLogin = "sms.email.body_login" - KeySMSEmailSubjectDelete = "sms.email.subject_delete" - KeySMSEmailBodyDelete = "sms.email.body_delete" + KeySMSEmailSubjectLogin = "sms.email.subject_login" + KeySMSEmailBodyLogin = "sms.email.body_login" + KeySMSEmailSubjectDelete = "sms.email.subject_delete" + KeySMSEmailBodyDelete = "sms.email.body_delete" NotifyKickReplaced = "notify.kick.replaced" NotifyKickLogout = "notify.kick.logout" diff --git a/internal/transport/http/admin_test.go b/internal/transport/http/admin_test.go index a474a56..174d5f4 100644 --- a/internal/transport/http/admin_test.go +++ b/internal/transport/http/admin_test.go @@ -23,7 +23,7 @@ func TestAdminImStatsRequiresJWT(t *testing.T) { if w.Code != http.StatusUnauthorized { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } - var env adminhandler.ApiResponse + var env adminhandler.APIResponse if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestAdminImStatsWithToken(t *testing.T) { // nil db → 500 with admin: mysql not ready message in Fail body t.Fatalf("status %d body %s", w.Code, w.Body.String()) } - var env adminhandler.ApiResponse + var env adminhandler.APIResponse if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } @@ -76,7 +76,7 @@ func TestAdminPopAlias(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status %d %s", w.Code, w.Body.String()) } - var env adminhandler.ApiResponse + var env adminhandler.APIResponse if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go index 9b03f6b..e16f492 100644 --- a/internal/transport/http/friend_test.go +++ b/internal/transport/http/friend_test.go @@ -54,7 +54,7 @@ type ginEngineBundle struct { engine http.Handler } -func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken { +func loginUser(t *testing.T, h http.Handler, email string, _ uint64) authentity.IssuedToken { t.Helper() body := authlogictest.LoginBodyJSONNamed(email, "any", "phone") req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body)) diff --git a/internal/transport/http/handler/admin/im_groups.go b/internal/transport/http/handler/admin/im_groups.go index 3370960..fd06c3a 100644 --- a/internal/transport/http/handler/admin/im_groups.go +++ b/internal/transport/http/handler/admin/im_groups.go @@ -136,7 +136,8 @@ func (h *Handler) ExportGroupZip(c *gin.Context) { csvBuf := &bytes.Buffer{} w := csv.NewWriter(csvBuf) _ = w.Write([]string{"userId", "email", "nickname", "role"}) - for _, m := range g.Members { + for i := range g.Members { + m := &g.Members[i] _ = w.Write([]string{ strconv.FormatInt(m.UserID, 10), m.Email, m.Nickname, m.Role, }) @@ -212,7 +213,7 @@ func (h *Handler) OpsCatalog(c *gin.Context) { }) } -func (h *Handler) ApiDoc(c *gin.Context) { +func (h *Handler) APIDoc(c *gin.Context) { OK(c, gin.H{ "title": "Spark IM V3 Admin API", "paths": []gin.H{ diff --git a/internal/transport/http/handler/admin/response.go b/internal/transport/http/handler/admin/response.go index 3ec8149..707c762 100644 --- a/internal/transport/http/handler/admin/response.go +++ b/internal/transport/http/handler/admin/response.go @@ -6,24 +6,24 @@ import ( "github.com/gin-gonic/gin" ) -// ApiResponse matches Java com.aliyun.control.dto.ApiResponse for popadmin.js. -type ApiResponse struct { +// APIResponse matches Java com.aliyun.control.dto.ApiResponse for popadmin.js. +type APIResponse struct { Success bool `json:"success"` Message string `json:"message"` Data any `json:"data"` } func OK(c *gin.Context, data any) { - c.JSON(http.StatusOK, ApiResponse{Success: true, Message: "ok", Data: data}) + c.JSON(http.StatusOK, APIResponse{Success: true, Message: "ok", Data: data}) } func OKMsg(c *gin.Context, msg string, data any) { - c.JSON(http.StatusOK, ApiResponse{Success: true, Message: msg, Data: data}) + c.JSON(http.StatusOK, APIResponse{Success: true, Message: msg, Data: data}) } func Fail(c *gin.Context, status int, msg string) { if status <= 0 { status = http.StatusBadRequest } - c.JSON(status, ApiResponse{Success: false, Message: msg, Data: nil}) + c.JSON(status, APIResponse{Success: false, Message: msg, Data: nil}) } diff --git a/internal/transport/http/handler/sms/handler.go b/internal/transport/http/handler/sms/handler.go index 87c802a..07b7063 100644 --- a/internal/transport/http/handler/sms/handler.go +++ b/internal/transport/http/handler/sms/handler.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - smslogic "spark-server/internal/module/sms/logic" smsentity "spark-server/internal/module/sms/entity" + smslogic "spark-server/internal/module/sms/logic" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index f6ed3a3..464b502 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -19,8 +19,8 @@ import ( metahandler "spark-server/internal/transport/http/handler/meta" momenthandler "spark-server/internal/transport/http/handler/moment" musichandler "spark-server/internal/transport/http/handler/music" - squarehandler "spark-server/internal/transport/http/handler/square" smshandler "spark-server/internal/transport/http/handler/sms" + squarehandler "spark-server/internal/transport/http/handler/square" userhandler "spark-server/internal/transport/http/handler/user" "spark-server/internal/transport/http/respond" ) diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go index 09687c9..a808dc6 100644 --- a/internal/transport/http/router_admin.go +++ b/internal/transport/http/router_admin.go @@ -22,7 +22,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { ug := r.Group("/api/admin") ug.Use(auth) { - ug.GET("/api-doc", h.ApiDoc) + ug.GET("/api-doc", h.APIDoc) ug.GET("/users", h.ListUsers) ug.GET("/users/by-email", h.UserByEmail) ug.GET("/users/by-nickname", h.UserByNickname) @@ -49,7 +49,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { } func registerSparkAdmin(g *gin.RouterGroup, h *adminhandler.Handler) { - g.GET("/api-doc", h.ApiDoc) + g.GET("/api-doc", h.APIDoc) g.GET("/ops-catalog", h.OpsCatalog) g.GET("/im/stats", h.ImStats) diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 6a0854d..f10999f 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -20,8 +20,8 @@ import ( msglogic "spark-server/internal/module/message/logic" momentlogic "spark-server/internal/module/moment/logic" musiclogic "spark-server/internal/module/music/logic" - squarelogic "spark-server/internal/module/square/logic" smslogic "spark-server/internal/module/sms/logic" + squarelogic "spark-server/internal/module/square/logic" synclogic "spark-server/internal/module/sync/logic" uploadlogic "spark-server/internal/module/upload/logic" userlogic "spark-server/internal/module/user/logic"