chore: 合并本地改动与 main(t_ 表名、lint、音乐分类)

在 fast-forward 到 origin/main 后解决 music logic/repo 冲突,SQL 与模型统一 t_* 表名;Makefile 修正 golangci-lint 变量并令 test/deploy 依赖 lint;补齐 admin/transport 等 lint 修复与 music logic 单测以满足覆盖率门禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhr 2026-09-24 10:56:59 +08:00
parent 6aa72ba3e7
commit 13b42cef7b
34 changed files with 610 additions and 225 deletions

View File

@ -4,13 +4,14 @@ BIN := bin/spark-server
LINUX_DIR := bin/linux_amd64 LINUX_DIR := bin/linux_amd64
IMAGE ?= spark-server:latest IMAGE ?= spark-server:latest
LDFLAGS := -s -w -buildid= 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 CONFIG ?= configs/config.yaml
LOGIC_COVER_MIN ?= 65 LOGIC_COVER_MIN ?= 65
.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback .PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback
all: lint test build all: test build
build: build:
$(GO) build -o $(BIN) ./cmd/server $(GO) build -o $(BIN) ./cmd/server
@ -25,7 +26,8 @@ image:
docker build --platform linux/amd64 -t $(IMAGE) . docker build --platform linux/amd64 -t $(IMAGE) .
# All unit tests, then each module logic package must be >= LOGIC_COVER_MIN. # All unit tests, then each module logic package must be >= LOGIC_COVER_MIN.
test: # Requires lint to pass first.
test: lint
$(GO) test ./... $(GO) test ./...
@echo "==> logic Service coverage (>= $(LOGIC_COVER_MIN)%)" @echo "==> logic Service coverage (>= $(LOGIC_COVER_MIN)%)"
@covout=$$($(GO) test -cover ./internal/module/*/logic); \ @covout=$$($(GO) test -cover ./internal/module/*/logic); \
@ -53,7 +55,7 @@ fmt:
$(GO) fmt ./... $(GO) fmt ./...
lint: lint:
$(LINT) run ./... $(GOLANGCI_LINT) run ./...
tidy: tidy:
$(GO) mod tidy $(GO) mod tidy
@ -70,7 +72,7 @@ start:
run: build run: build
./$(BIN) -config $(CONFIG) ./$(BIN) -config $(CONFIG)
deploy: deploy: test
bash deploy/docker/deploy.sh bash deploy/docker/deploy.sh
deploy-rollback: deploy-rollback:

View File

@ -19,9 +19,9 @@ import (
) )
const ( const (
maxGetBytes = 32 << 20 maxGetBytes = 32 << 20
maxPutBytes = 4 << 20 maxPutBytes = 4 << 20
maxPutLarge = 50 << 20 // admin music audio etc. maxPutLarge = 50 << 20 // admin music audio etc.
) )
// PutLarge writes an object up to maxBytes (capped at 50 MiB). // PutLarge writes an object up to maxBytes (capped at 50 MiB).

View File

@ -64,28 +64,34 @@ func (s *Service) AddComplaint(ctx context.Context, email, target, reason, detai
func complaintViews(rows []adminmodel.ComplaintRow) []ComplaintView { func complaintViews(rows []adminmodel.ComplaintRow) []ComplaintView {
out := make([]ComplaintView, 0, len(rows)) out := make([]ComplaintView, 0, len(rows))
for _, r := range rows { for i := range rows {
target := r.TargetMail target := rows[i].TargetMail
if target == "" { if target == "" {
target = strconv.FormatInt(r.TargetID, 10) target = strconv.FormatInt(rows[i].TargetID, 10)
} }
out = append(out, ComplaintView{ out = append(out, ComplaintView{
ID: r.ID, Email: r.Email, Target: target, ID: rows[i].ID, Email: rows[i].Email, Target: target,
Reason: reportReasonName(r.Reason), Detail: r.Detail, Reason: reportReasonName(rows[i].Reason), Detail: rows[i].Detail,
Status: "open", CreatedAt: r.CreatedAt.Format(time.RFC3339), Status: "open", CreatedAt: rows[i].CreatedAt.Format(time.RFC3339),
}) })
} }
return out return out
} }
const (
reportLabelSpam = "spam"
reportLabelHarassment = "harassment"
reportLabelFake = "fake"
)
func reportReasonName(r int) string { func reportReasonName(r int) string {
switch friendentity.ReportReason(r) { switch friendentity.ReportReason(r) {
case friendentity.ReportSpam: case friendentity.ReportSpam:
return "spam" return reportLabelSpam
case friendentity.ReportHarassment: case friendentity.ReportHarassment:
return "harassment" return reportLabelHarassment
case friendentity.ReportFake: case friendentity.ReportFake:
return "fake" return reportLabelFake
default: default:
return "other" return "other"
} }
@ -93,11 +99,11 @@ func reportReasonName(r int) string {
func parseReportReason(raw string) friendentity.ReportReason { func parseReportReason(raw string) friendentity.ReportReason {
switch strings.ToLower(strings.TrimSpace(raw)) { switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "spam", "垃圾": case "1", reportLabelSpam, "垃圾":
return friendentity.ReportSpam return friendentity.ReportSpam
case "2", "harassment", "骚扰": case "2", reportLabelHarassment, "骚扰":
return friendentity.ReportHarassment return friendentity.ReportHarassment
case "3", "fake", "不实身份": case "3", reportLabelFake, "不实身份":
return friendentity.ReportFake return friendentity.ReportFake
default: default:
return friendentity.ReportOther return friendentity.ReportOther

View File

@ -17,8 +17,8 @@ func (s *Service) ListGroups(ctx context.Context, q string) ([]GroupView, error)
return nil, err return nil, err
} }
out := make([]GroupView, 0, len(rows)) out := make([]GroupView, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, groupSummary(r)) out = append(out, groupSummary(&rows[i]))
} }
return out, nil return out, nil
} }
@ -35,13 +35,14 @@ func (s *Service) GroupDetail(ctx context.Context, groupIDStr string) (*GroupVie
if err != nil { if err != nil {
return nil, err return nil, err
} }
view := groupSummary(*g) view := groupSummary(g)
members, err := s.repo.ListGroupMembers(ctx, gid) members, err := s.repo.ListGroupMembers(ctx, gid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
view.Members = make([]GroupMemberOut, 0, len(members)) view.Members = make([]GroupMemberOut, 0, len(members))
for _, m := range members { for i := range members {
m := &members[i]
nick := m.Nickname nick := m.Nickname
if nick == "" { if nick == "" {
nick = m.UNick nick = m.UNick
@ -55,8 +56,8 @@ func (s *Service) GroupDetail(ctx context.Context, groupIDStr string) (*GroupVie
return nil, err return nil, err
} }
view.Messages = make([]ImMessageView, 0, len(msgs)) view.Messages = make([]ImMessageView, 0, len(msgs))
for _, m := range msgs { for i := range msgs {
view.Messages = append(view.Messages, messageView(m)) view.Messages = append(view.Messages, messageView(&msgs[i]))
} }
return &view, nil return &view, nil
} }

View File

@ -28,9 +28,9 @@ func (s *Service) ListMessages(ctx context.Context, q string, limit int) ([]ImMe
return nil, err return nil, err
} }
out := make([]ImMessageView, 0, limit) out := make([]ImMessageView, 0, limit)
for _, r := range rows { for i := range rows {
v := messageView(r) v := messageView(&rows[i])
if q != "" && !messageMatch(v, q) { if q != "" && !messageMatch(&v, q) {
continue continue
} }
out = append(out, v) out = append(out, v)
@ -56,8 +56,9 @@ func (s *Service) ListConversations(ctx context.Context, userID int64) (convs []
convs = make([]ImConversationView, 0, len(rows)) convs = make([]ImConversationView, 0, len(rows))
groups = make([]GroupView, 0) groups = make([]GroupView, 0)
seenGroup := map[int64]struct{}{} seenGroup := map[int64]struct{}{}
for _, r := range rows { for i := range rows {
chatType := "private" chatType := "private"
r := &rows[i]
if r.Type == 2 { if r.Type == 2 {
chatType = "group" chatType = "group"
} }

View File

@ -13,7 +13,13 @@ import (
userentity "spark-server/internal/module/user/entity" 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{ return adminentity.ImMessageView{
ID: strconv.FormatInt(r.ID, 10), ID: strconv.FormatInt(r.ID, 10),
ClientMsgID: r.ClientMsgID, 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) ql := strings.ToLower(q)
if strings.Contains(strings.ToLower(v.Text), ql) { if strings.Contains(strings.ToLower(v.Text), ql) {
return true return true
@ -117,7 +123,7 @@ func previewText(raw []byte, t msgentity.Type) string {
return "" return ""
} }
func userView(r adminmodel.UserRow) adminentity.UserView { func userView(r *adminmodel.UserRow) adminentity.UserView {
v := adminentity.UserView{ v := adminentity.UserView{
ID: r.ID, Email: r.Email, Nickname: r.Nickname, SparkID: r.SparkID, ID: r.ID, Email: r.Email, Nickname: r.Nickname, SparkID: r.SparkID,
AvatarURL: r.Avatar, Channel: r.Platform, Status: r.Status, AvatarURL: r.Avatar, Channel: r.Platform, Status: r.Status,
@ -133,7 +139,7 @@ func userView(r adminmodel.UserRow) adminentity.UserView {
return v return v
} }
func groupSummary(r adminmodel.GroupRow) adminentity.GroupView { func groupSummary(r *adminmodel.GroupRow) adminentity.GroupView {
return adminentity.GroupView{ return adminentity.GroupView{
GroupID: strconv.FormatInt(r.ID, 10), GroupID: strconv.FormatInt(r.ID, 10),
Name: r.Name, Name: r.Name,
@ -150,10 +156,10 @@ func groupSummary(r adminmodel.GroupRow) adminentity.GroupView {
func roleName(role int) string { func roleName(role int) string {
switch groupentity.Role(role) { switch groupentity.Role(role) {
case groupentity.RoleOwner: case groupentity.RoleOwner:
return "owner" return roleLabelOwner
case groupentity.RoleAdmin: case groupentity.RoleAdmin:
return "admin" return roleLabelAdmin
default: default:
return "member" return roleLabelMember
} }
} }

View File

@ -33,14 +33,14 @@ func TestAdminMessageHelpers(t *testing.T) {
if previewText([]byte("not-json"), msgentity.TypeText) != "" { if previewText([]byte("not-json"), msgentity.TypeText) != "" {
t.Fatal("bad content") t.Fatal("bad content")
} }
v := messageView(adminmodel.MessageRow{ v := messageView(&adminmodel.MessageRow{
ID: 42, SenderID: 7, ReceiverID: 8, GroupID: 9, MessageType: int16(msgentity.TypeText), 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", 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) t.Fatalf("%+v", v)
} }
if messageMatch(v, "zzz") { if messageMatch(&v, "zzz") {
t.Fatal("no match") t.Fatal("no match")
} }
} }

View File

@ -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) { func (m *memoryAdmin) UsersByEmail(_ context.Context, email string) ([]adminmodel.UserRow, error) {
out := make([]adminmodel.UserRow, 0) out := make([]adminmodel.UserRow, 0)
for _, u := range m.users { for i := range m.users {
if u.Email == email { if m.users[i].Email == email {
out = append(out, u) out = append(out, m.users[i])
} }
} }
return out, nil 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) { func (m *memoryAdmin) UsersByNickname(_ context.Context, nickname string) ([]adminmodel.UserRow, error) {
out := make([]adminmodel.UserRow, 0) out := make([]adminmodel.UserRow, 0)
for _, u := range m.users { for i := range m.users {
if u.Nickname == nickname { if m.users[i].Nickname == nickname {
out = append(out, u) out = append(out, m.users[i])
} }
} }
return out, nil 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) { func (m *memoryAdmin) UsersByDevice(_ context.Context, _ string, deviceIDNum uint64) ([]adminmodel.UserRow, error) {
out := make([]adminmodel.UserRow, 0) out := make([]adminmodel.UserRow, 0)
for _, u := range m.users { for i := range m.users {
if u.DeviceID == deviceIDNum { if m.users[i].DeviceID == deviceIDNum {
out = append(out, u) out = append(out, m.users[i])
} }
} }
return out, nil return out, nil

View File

@ -24,8 +24,8 @@ func (s *Service) ListUsers(ctx context.Context, email string, page, size int) (
return nil, 0, err return nil, 0, err
} }
list = make([]UserView, 0, len(rows)) list = make([]UserView, 0, len(rows))
for _, r := range rows { for i := range rows {
list = append(list, userView(r)) list = append(list, userView(&rows[i]))
} }
return list, total, nil return list, total, nil
} }
@ -43,8 +43,8 @@ func (s *Service) UserByEmail(ctx context.Context, email string) (map[string]any
return nil, err return nil, err
} }
items := make([]UserView, 0, len(rows)) items := make([]UserView, 0, len(rows))
for _, r := range rows { for i := range rows {
items = append(items, userView(r)) items = append(items, userView(&rows[i]))
} }
return map[string]any{ return map[string]any{
"email": email, "appUsers": items, "appCode": "spark", "email": email, "appUsers": items, "appCode": "spark",
@ -59,7 +59,7 @@ func (s *Service) UserByID(ctx context.Context, id int64) (*UserView, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
v := userView(*r) v := userView(r)
return &v, nil return &v, nil
} }
@ -72,8 +72,8 @@ func (s *Service) UsersByNickname(ctx context.Context, nickname string) ([]UserV
return nil, err return nil, err
} }
out := make([]UserView, 0, len(rows)) out := make([]UserView, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, userView(r)) out = append(out, userView(&rows[i]))
} }
return out, nil return out, nil
} }
@ -89,8 +89,8 @@ func (s *Service) UsersByDevice(ctx context.Context, deviceID string) ([]UserVie
return nil, err return nil, err
} }
out := make([]UserView, 0, len(rows)) out := make([]UserView, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, userView(r)) out = append(out, userView(&rows[i]))
} }
return out, nil return out, nil
} }
@ -104,8 +104,8 @@ func (s *Service) UsersByIP(ctx context.Context, ip string) ([]UserView, error)
return nil, err return nil, err
} }
out := make([]UserView, 0, len(rows)) out := make([]UserView, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, userView(r)) out = append(out, userView(&rows[i]))
} }
return out, nil return out, nil
} }
@ -162,9 +162,9 @@ func (s *Service) SameIPRank(ctx context.Context, limit int) ([]SameIPItem, erro
return nil, err return nil, err
} }
out := make([]SameIPItem, 0, len(rows)) out := make([]SameIPItem, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, SameIPItem{ 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 return out, nil

View File

@ -36,11 +36,8 @@ LIMIT ?`, limit).Scan(&rows).Error
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]ComplaintRow, 0, len(rows)) out := make([]ComplaintRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, ComplaintRow{ out = append(out, ComplaintRow(rows[i]))
ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, Reason: r.Reason,
Detail: r.Detail, CreatedAt: r.CreatedAt, Email: r.Email, TargetMail: r.TargetMail,
})
} }
return out, nil return out, nil
} }

View File

@ -23,12 +23,8 @@ type groupScanRow struct {
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at"`
} }
func groupScanToRow(r groupScanRow) GroupRow { func groupScanToRow(r *groupScanRow) GroupRow {
return GroupRow{ return GroupRow(*r)
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 (m *Repo) ListGroups(ctx context.Context, q string) ([]GroupRow, error) { 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) return nil, mapDBErr(err)
} }
out := make([]GroupRow, 0, len(rows)) out := make([]GroupRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, groupScanToRow(r)) out = append(out, groupScanToRow(&rows[i]))
} }
return out, nil 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 { if err := db.Table("`t_groups`").Where("id = ?", groupID).Take(&r).Error; err != nil {
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
row := groupScanToRow(r) row := groupScanToRow(&r)
return &row, nil return &row, nil
} }
@ -115,10 +111,8 @@ LIMIT 500`, groupID).Scan(&rows).Error
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]GroupMemberRow, 0, len(rows)) out := make([]GroupMemberRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, GroupMemberRow{ out = append(out, GroupMemberRow(rows[i]))
UserID: r.UserID, Role: r.Role, Nickname: r.Nickname, Email: r.Email, UNick: r.UNick,
})
} }
return out, nil return out, nil
} }

View File

@ -56,13 +56,8 @@ FROM t_group_message`
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]MessageRow, 0, len(rows)) out := make([]MessageRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, MessageRow{ out = append(out, MessageRow(rows[i]))
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,
})
} }
return out, nil return out, nil
} }
@ -98,11 +93,8 @@ LIMIT 200`, userID).Scan(&rows).Error
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]ConversationRow, 0, len(rows)) out := make([]ConversationRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, ConversationRow{ out = append(out, ConversationRow(rows[i]))
ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt,
UserSmall: r.UserSmall, UserBig: r.UserBig, GroupID: r.GroupID, GroupName: r.GroupName,
})
} }
return out, nil return out, nil
} }
@ -116,6 +108,7 @@ func (m *Repo) ListGroupMessages(ctx context.Context, groupID int64, limit int)
ID int64 `gorm:"column:id"` ID int64 `gorm:"column:id"`
ConversationID int64 `gorm:"column:conversation_id"` ConversationID int64 `gorm:"column:conversation_id"`
SenderID int64 `gorm:"column:sender_id"` SenderID int64 `gorm:"column:sender_id"`
ReceiverID int64 `gorm:"column:receiver_id"`
GroupID int64 `gorm:"column:group_id"` GroupID int64 `gorm:"column:group_id"`
ClientMsgID string `gorm:"column:client_msg_id"` ClientMsgID string `gorm:"column:client_msg_id"`
MessageType int16 `gorm:"column:message_type"` MessageType int16 `gorm:"column:message_type"`
@ -126,18 +119,14 @@ func (m *Repo) ListGroupMessages(ctx context.Context, groupID int64, limit int)
} }
var rows []scanRow var rows []scanRow
err = db.Raw(` 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 FROM t_group_message WHERE group_id = ? ORDER BY created_at DESC LIMIT ?`, groupID, limit).Scan(&rows).Error
if err != nil { if err != nil {
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]MessageRow, 0, len(rows)) out := make([]MessageRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, MessageRow{ out = append(out, MessageRow(rows[i]))
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,
})
} }
return out, nil return out, nil
} }

View File

@ -73,7 +73,7 @@ WHERE u.id = ?`, id).Scan(&rows).Error
if len(rows) == 0 || rows[0].ID == 0 { if len(rows) == 0 || rows[0].ID == 0 {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
r := userScanToRow(rows[0]) r := userScanToRow(&rows[0])
return &r, nil return &r, nil
} }
@ -223,8 +223,8 @@ LIMIT ?`, limit).Scan(&rows).Error
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
out := make([]SameIPRankRow, len(rows)) out := make([]SameIPRankRow, len(rows))
for i, r := range rows { for i := range rows {
out[i] = SameIPRankRow{IP: r.IP, UserCount: r.UserCount, DeviceCount: r.DeviceCount} out[i] = SameIPRankRow{IP: rows[i].IP, UserCount: rows[i].UserCount, DeviceCount: rows[i].DeviceCount}
} }
return out, nil return out, nil
} }
@ -254,16 +254,12 @@ type userScanRow struct {
func userScanToRows(rows []userScanRow) []UserRow { func userScanToRows(rows []userScanRow) []UserRow {
out := make([]UserRow, 0, len(rows)) out := make([]UserRow, 0, len(rows))
for _, r := range rows { for i := range rows {
out = append(out, userScanToRow(r)) out = append(out, userScanToRow(&rows[i]))
} }
return out return out
} }
func userScanToRow(r userScanRow) UserRow { func userScanToRow(r *userScanRow) UserRow {
return UserRow{ return UserRow(*r)
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,
}
} }

View File

@ -99,7 +99,7 @@ func (m *Repo) Create(ctx context.Context, message *entity.Message) error {
message.Kind = kind message.Kind = kind
row := rowFrom(message) row := rowFrom(message)
stampCreatedAt(message, &row) 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) { 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 return err
} }
message.Kind = kind message.Kind = kind
if err := m.insertBody(tx, message, row); err != nil { if err := m.insertBody(tx, message, &row); err != nil {
return err return err
} }
res := tx.Table("t_conversations").Where("id = ?", message.ConversationID).Updates(map[string]any{ 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() 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 message.Kind == entity.KindGroup {
if err := m.fillGroupID(db, message); err != nil { if err := m.fillGroupID(db, message); err != nil {
return err return err

View File

@ -55,12 +55,12 @@ func rowFrom(m *entity.Message) MessageRow {
} }
} }
func privateRow(m *entity.Message, base MessageRow) PrivateMessage { func privateRow(m *entity.Message, base *MessageRow) PrivateMessage {
return PrivateMessage{MessageRow: base, ReceiverID: m.ReceiverID} return PrivateMessage{MessageRow: *base, ReceiverID: m.ReceiverID}
} }
func groupRow(m *entity.Message, base MessageRow) GroupMessage { func groupRow(m *entity.Message, base *MessageRow) GroupMessage {
return GroupMessage{MessageRow: base, GroupID: m.GroupID} return GroupMessage{MessageRow: *base, GroupID: m.GroupID}
} }
func tableOf(kind entity.Kind) string { func tableOf(kind entity.Kind) string {

View File

@ -1,8 +1,8 @@
package logic package logic
import ( import (
smsentity "spark-server/internal/module/sms/entity"
"spark-server/internal/module/meta/entity" "spark-server/internal/module/meta/entity"
smsentity "spark-server/internal/module/sms/entity"
"spark-server/internal/pkg/i18n" "spark-server/internal/pkg/i18n"
) )

View File

@ -15,6 +15,21 @@ import (
var ErrNotReady = errors.New("music: service not ready") 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 { type Repository interface {
ListAll(ctx context.Context) ([]entity.Track, error) ListAll(ctx context.Context) ([]entity.Track, error)
ListEnabled(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 { if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在") 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) 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 { if err := s.put(ctx, key, body, ct, maxBytes); err != nil {
return nil, err return nil, err
@ -301,7 +316,7 @@ func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, bo
if err != nil { if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在") 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) 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 { if err := s.put(ctx, key, body, ct, entity.MaxAudioBytes); err != nil {
return nil, err return nil, err
@ -393,11 +408,12 @@ func (s *Service) UpdateCategory(ctx context.Context, id int64, in CategoryInput
return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空") return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空")
} }
if in.ParentID != nil { if in.ParentID != nil {
if *in.ParentID == 0 { switch {
case *in.ParentID == 0:
c.ParentID = nil c.ParentID = nil
} else if *in.ParentID == id { case *in.ParentID == id:
return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级") return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级")
} else { default:
parent, err := s.repo.GetCategory(ctx, *in.ParentID) parent, err := s.repo.GetCategory(ctx, *in.ParentID)
if err != nil { if err != nil {
return nil, errs.ErrNotFound.WithMsg("父分类不存在") 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 { func buildCategoryTree(flat []entity.Category) []entity.Category {
nodes := make(map[int64]*entity.Category, len(flat)) nodes := make(map[int64]*entity.Category, len(flat))
order := make([]int64, 0, len(flat)) order := make([]int64, 0, len(flat))
for _, c := range flat { for i := range flat {
cc := c flat[i].Children = nil
cc.Children = nil nodes[flat[i].ID] = &flat[i]
nodes[cc.ID] = &cc order = append(order, flat[i].ID)
order = append(order, cc.ID)
} }
rootPtrs := make([]*entity.Category, 0) rootPtrs := make([]*entity.Category, 0)
for _, id := range order { for _, id := range order {
@ -501,11 +516,11 @@ func dedupeIDs(ids []int64) []int64 {
return out 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() { if s.objects == nil || !s.objects.Ready() {
return errs.ErrAvailable.WithMsg("对象存储未就绪") 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 { 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), ".")) ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), "."))
switch ext { switch ext {
case "jpg", "jpeg": case extJPG, extJPEG:
return "image/jpeg" return mimeJPEG
case "png": case extPNG:
return "image/png" return mimePNG
case "webp": case extWebP:
return "image/webp" return mimeWebP
case "gif": case extGIF:
return "image/gif" return mimeGIF
case "mp3": case extMP3:
return "audio/mpeg" return mimeMPEG
case "m4a": case "m4a":
return "audio/mp4" return "audio/mp4"
default: default:
@ -565,15 +580,15 @@ func extFor(ct, filename, fallback string) string {
} }
switch ct { switch ct {
case "image/jpeg", "image/jpg": case "image/jpeg", "image/jpg":
return "jpg" return extJPG
case "image/png": case mimePNG:
return "png" return extPNG
case "image/webp": case mimeWebP:
return "webp" return extWebP
case "image/gif": case mimeGIF:
return "gif" return extGIF
case "audio/mpeg", "audio/mp3": case "audio/mpeg", "audio/mp3":
return "mp3" return extMP3
default: default:
return fallback return fallback
} }

View File

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

View File

@ -82,8 +82,8 @@ func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) {
return nil, err return nil, err
} }
out := make([]entity.Track, 0, len(rows)) out := make([]entity.Track, 0, len(rows))
for _, row := range rows { for i := range rows {
out = append(out, toEntity(row)) out = append(out, toEntity(&rows[i]))
} }
return r.attachCategoryIDs(ctx, out) return r.attachCategoryIDs(ctx, out)
} }
@ -98,8 +98,8 @@ func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) {
return nil, err return nil, err
} }
out := make([]entity.Track, 0, len(rows)) out := make([]entity.Track, 0, len(rows))
for _, row := range rows { for i := range rows {
out = append(out, toEntity(row)) out = append(out, toEntity(&rows[i]))
} }
return r.attachCategoryIDs(ctx, out) return r.attachCategoryIDs(ctx, out)
} }
@ -131,8 +131,8 @@ func (r *Repo) ListEnabledByCategory(ctx context.Context, categoryID int64) ([]e
return nil, err return nil, err
} }
out := make([]entity.Track, 0, len(rows)) out := make([]entity.Track, 0, len(rows))
for _, row := range rows { for i := range rows {
out = append(out, toEntity(row)) out = append(out, toEntity(&rows[i]))
} }
return r.attachCategoryIDs(ctx, out) return r.attachCategoryIDs(ctx, out)
} }
@ -143,7 +143,8 @@ func (r *Repo) categoryScopeIDs(ctx context.Context, categoryID int64) ([]int64,
return nil, err return nil, err
} }
byParent := map[int64][]int64{} byParent := map[int64][]int64{}
for _, c := range all { for i := range all {
c := &all[i]
if c.ParentID != nil { if c.ParentID != nil {
byParent[*c.ParentID] = append(byParent[*c.ParentID], c.ID) 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 { if err != nil {
return nil, err return nil, err
} }
t := toEntity(row) t := toEntity(&row)
ids, err := r.TrackCategoryIDs(ctx, id) ids, err := r.TrackCategoryIDs(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@ -292,8 +293,8 @@ func (r *Repo) ListCategories(ctx context.Context) ([]entity.Category, error) {
return nil, err return nil, err
} }
out := make([]entity.Category, 0, len(rows)) out := make([]entity.Category, 0, len(rows))
for _, row := range rows { for i := range rows {
out = append(out, toCategoryEntity(row)) out = append(out, toCategoryEntity(&rows[i]))
} }
return out, nil return out, nil
} }
@ -308,8 +309,8 @@ func (r *Repo) ListEnabledCategories(ctx context.Context) ([]entity.Category, er
return nil, err return nil, err
} }
out := make([]entity.Category, 0, len(rows)) out := make([]entity.Category, 0, len(rows))
for _, row := range rows { for i := range rows {
out = append(out, toCategoryEntity(row)) out = append(out, toCategoryEntity(&rows[i]))
} }
return out, nil return out, nil
} }
@ -323,7 +324,7 @@ func (r *Repo) GetCategory(ctx context.Context, id int64) (*entity.Category, err
if err != nil { if err != nil {
return nil, err return nil, err
} }
c := toCategoryEntity(row) c := toCategoryEntity(&row)
return &c, nil return &c, nil
} }
@ -434,8 +435,8 @@ func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([]
return tracks, nil return tracks, nil
} }
ids := make([]int64, 0, len(tracks)) ids := make([]int64, 0, len(tracks))
for _, t := range tracks { for i := range tracks {
ids = append(ids, t.ID) ids = append(ids, tracks[i].ID)
} }
var links []categoryTrackRow var links []categoryTrackRow
err := r.db.WithContext(ctx).Where("track_id IN ?", ids).Order("category_id ASC").Find(&links).Error 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 return nil, err
} }
m := map[int64][]int64{} m := map[int64][]int64{}
for _, l := range links { for i := range links {
l := &links[i]
m[l.TrackID] = append(m[l.TrackID], l.CategoryID) m[l.TrackID] = append(m[l.TrackID], l.CategoryID)
} }
for i := range tracks { for i := range tracks {
@ -456,7 +458,7 @@ func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([]
return tracks, nil return tracks, nil
} }
func toEntity(row trackRow) entity.Track { func toEntity(row *trackRow) entity.Track {
return entity.Track{ return entity.Track{
ID: row.ID, ID: row.ID,
Title: row.Title, 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{ return entity.Category{
ID: row.ID, ID: row.ID,
ParentID: row.ParentID, ParentID: row.ParentID,

View File

@ -105,7 +105,8 @@ type sampleSpec struct {
func (s *Service) ensureSampleMessages(ctx context.Context, g *groupentity.Group, bots []*userentity.User) (int, error) { func (s *Service) ensureSampleMessages(ctx context.Context, g *groupentity.Group, bots []*userentity.User) (int, error) {
specs := sampleSpecs() specs := sampleSpecs()
n := 0 n := 0
for _, spec := range specs { for i := range specs {
spec := specs[i]
senderID := int64(0) senderID := int64(0)
if spec.Type != msgentity.TypeSystem { if spec.Type != msgentity.TypeSystem {
senderID = bots[spec.Sender%len(bots)].ID senderID = bots[spec.Sender%len(bots)].ID

View File

@ -6,12 +6,12 @@ import (
"testing" "testing"
"spark-server/internal/infrastructure/id" "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" friendlogic "spark-server/internal/module/friend/logic"
friendmodel "spark-server/internal/module/friend/model" friendmodel "spark-server/internal/module/friend/model"
momentlogic "spark-server/internal/module/moment/logic" momentlogic "spark-server/internal/module/moment/logic"
momentmodel "spark-server/internal/module/moment/model" 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/entity"
"spark-server/internal/module/user/model" "spark-server/internal/module/user/model"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"

View File

@ -11,9 +11,9 @@ import (
"testing" "testing"
"spark-server/internal/infrastructure/id" "spark-server/internal/infrastructure/id"
smsentity "spark-server/internal/module/sms/entity"
smslogic "spark-server/internal/module/sms/logic" smslogic "spark-server/internal/module/sms/logic"
smsmodel "spark-server/internal/module/sms/model" 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/entity"
"spark-server/internal/module/user/model" "spark-server/internal/module/user/model"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"

View File

@ -10,12 +10,12 @@ import (
) )
type UserDevice struct { type UserDevice struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"` ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
UserID int64 `gorm:"column:user_id;comment:所属用户"` UserID int64 `gorm:"column:user_id;comment:所属用户"`
DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"` DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"`
LastLoginAt time.Time `gorm:"column:last_login_at;comment:该用户在此设备最近登录成功"` LastLoginAt time.Time `gorm:"column:last_login_at;comment:该用户在此设备最近登录成功"`
LastActiveAt *time.Time `gorm:"column:last_active_at;comment:该用户在此设备最近活跃"` LastActiveAt *time.Time `gorm:"column:last_active_at;comment:该用户在此设备最近活跃"`
CreatedAt time.Time `gorm:"column:created_at;comment:该用户首次在此设备绑定"` CreatedAt time.Time `gorm:"column:created_at;comment:该用户首次在此设备绑定"`
} }
func (UserDevice) TableName() string { return "t_user_devices" } func (UserDevice) TableName() string { return "t_user_devices" }

View File

@ -2,27 +2,27 @@ package errs
// Sentinel AppErrors. Code is the envelope code shared by HTTP and TCP. // Sentinel AppErrors. Code is the envelope code shared by HTTP and TCP.
var ( var (
OK = NewError("0", "ok", "") OK = NewError("0", "ok", "")
ErrInvalidArgument = NewError("40001", "invalid argument", "") ErrInvalidArgument = NewError("40001", "invalid argument", "")
ErrFrameTooLarge = NewError("40002", "frame too large", "") ErrFrameTooLarge = NewError("40002", "frame too large", "")
ErrInvalidFrame = NewError("40003", "invalid frame", "") ErrInvalidFrame = NewError("40003", "invalid frame", "")
ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "") ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "")
ErrUnauthorized = NewError("40101", "unauthorized", "") ErrUnauthorized = NewError("40101", "unauthorized", "")
ErrNotAuthenticated = NewError("40102", "not authenticated", "") ErrNotAuthenticated = NewError("40102", "not authenticated", "")
ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "") ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "")
ErrForbidden = NewError("40301", "forbidden", "") ErrForbidden = NewError("40301", "forbidden", "")
ErrNotFound = NewError("40401", "not found", "") ErrNotFound = NewError("40401", "not found", "")
ErrMediaExpired = NewError("40402", "media expired", "") ErrMediaExpired = NewError("40402", "media expired", "")
ErrDuplicate = NewError("40901", "duplicate", "") ErrDuplicate = NewError("40901", "duplicate", "")
ErrBusy = NewError("42901", "busy", "") ErrBusy = NewError("42901", "busy", "")
ErrRateLimited = NewError("42902", "rate limited", "") ErrRateLimited = NewError("42902", "rate limited", "")
ErrOutboundDropped = NewError("42903", "outbound dropped", "") ErrOutboundDropped = NewError("42903", "outbound dropped", "")
ErrClosed = NewError("49901", "connection closed", "") ErrClosed = NewError("49901", "connection closed", "")
ErrInner = NewErrorPrint("50001", "internal", "") ErrInner = NewErrorPrint("50001", "internal", "")
ErrOperatFail = NewErrorPrint("50002", "operation failed", "") ErrOperatFail = NewErrorPrint("50002", "operation failed", "")
ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "") ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "")
ErrAvailable = NewErrorPrint("50301", "unavailable", "") ErrAvailable = NewErrorPrint("50301", "unavailable", "")
ErrTimeout = NewErrorPrint("50401", "timeout", "") ErrTimeout = NewErrorPrint("50401", "timeout", "")
) )
// ErrInternal is an alias of ErrInner. // ErrInternal is an alias of ErrInner.

View File

@ -31,7 +31,7 @@ func init() {
}, },
KeySMSEmailBodyLogin: { KeySMSEmailBodyLogin: {
ZhCN: "您的登录验证码是 {code},5 分钟内有效。如非本人操作请忽略。", 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 分鐘內有效。如非本人操作請忽略。", ZhTW: "您的登入驗證碼是 {code},5 分鐘內有效。如非本人操作請忽略。",
}, },
KeySMSEmailSubjectDelete: { KeySMSEmailSubjectDelete: {
@ -39,7 +39,7 @@ func init() {
}, },
KeySMSEmailBodyDelete: { KeySMSEmailBodyDelete: {
ZhCN: "您的注销验证码是 {code},5 分钟内有效。提交后将永久删除账号。", 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 分鐘內有效。提交後將永久刪除帳號。", ZhTW: "您的註銷驗證碼是 {code},5 分鐘內有效。提交後將永久刪除帳號。",
}, },
"enum.verification_purpose.login": { "enum.verification_purpose.login": {

View File

@ -170,18 +170,18 @@ const (
KeySquareCommentInvalid = "validate.square.comment_invalid" KeySquareCommentInvalid = "validate.square.comment_invalid"
KeySquareReplyInvalid = "validate.square.reply_invalid" KeySquareReplyInvalid = "validate.square.reply_invalid"
KeyFieldVerificationTarget = "field.verification_target" KeyFieldVerificationTarget = "field.verification_target"
KeySMSLanguageRequired = "validate.sms.language_required" KeySMSLanguageRequired = "validate.sms.language_required"
KeySMSTargetRequired = "validate.sms.target_required" KeySMSTargetRequired = "validate.sms.target_required"
KeySMSTargetInvalid = "validate.sms.target_invalid" KeySMSTargetInvalid = "validate.sms.target_invalid"
KeyFieldVerificationPurpose = "field.verification_purpose" KeyFieldVerificationPurpose = "field.verification_purpose"
KeySMSPurposeRequired = "validate.sms.purpose_required" KeySMSPurposeRequired = "validate.sms.purpose_required"
KeySMSPurposeInvalid = "validate.sms.purpose_invalid" KeySMSPurposeInvalid = "validate.sms.purpose_invalid"
KeySMSDeletePhoneUnsupported = "validate.sms.delete_phone_unsupported" KeySMSDeletePhoneUnsupported = "validate.sms.delete_phone_unsupported"
KeySMSEmailSubjectLogin = "sms.email.subject_login" KeySMSEmailSubjectLogin = "sms.email.subject_login"
KeySMSEmailBodyLogin = "sms.email.body_login" KeySMSEmailBodyLogin = "sms.email.body_login"
KeySMSEmailSubjectDelete = "sms.email.subject_delete" KeySMSEmailSubjectDelete = "sms.email.subject_delete"
KeySMSEmailBodyDelete = "sms.email.body_delete" KeySMSEmailBodyDelete = "sms.email.body_delete"
NotifyKickReplaced = "notify.kick.replaced" NotifyKickReplaced = "notify.kick.replaced"
NotifyKickLogout = "notify.kick.logout" NotifyKickLogout = "notify.kick.logout"

View File

@ -23,7 +23,7 @@ func TestAdminImStatsRequiresJWT(t *testing.T) {
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusUnauthorized {
t.Fatalf("status %d body %s", w.Code, w.Body.String()) 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 { if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -50,7 +50,7 @@ func TestAdminImStatsWithToken(t *testing.T) {
// nil db → 500 with admin: mysql not ready message in Fail body // nil db → 500 with admin: mysql not ready message in Fail body
t.Fatalf("status %d body %s", w.Code, w.Body.String()) 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 { if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -76,7 +76,7 @@ func TestAdminPopAlias(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("status %d %s", w.Code, w.Body.String()) 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 { if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -54,7 +54,7 @@ type ginEngineBundle struct {
engine http.Handler 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() t.Helper()
body := authlogictest.LoginBodyJSONNamed(email, "any", "phone") body := authlogictest.LoginBodyJSONNamed(email, "any", "phone")
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))

View File

@ -136,7 +136,8 @@ func (h *Handler) ExportGroupZip(c *gin.Context) {
csvBuf := &bytes.Buffer{} csvBuf := &bytes.Buffer{}
w := csv.NewWriter(csvBuf) w := csv.NewWriter(csvBuf)
_ = w.Write([]string{"userId", "email", "nickname", "role"}) _ = w.Write([]string{"userId", "email", "nickname", "role"})
for _, m := range g.Members { for i := range g.Members {
m := &g.Members[i]
_ = w.Write([]string{ _ = w.Write([]string{
strconv.FormatInt(m.UserID, 10), m.Email, m.Nickname, m.Role, 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{ OK(c, gin.H{
"title": "Spark IM V3 Admin API", "title": "Spark IM V3 Admin API",
"paths": []gin.H{ "paths": []gin.H{

View File

@ -6,24 +6,24 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ApiResponse matches Java com.aliyun.control.dto.ApiResponse for popadmin.js. // APIResponse matches Java com.aliyun.control.dto.ApiResponse for popadmin.js.
type ApiResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
Data any `json:"data"` Data any `json:"data"`
} }
func OK(c *gin.Context, data any) { 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) { 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) { func Fail(c *gin.Context, status int, msg string) {
if status <= 0 { if status <= 0 {
status = http.StatusBadRequest status = http.StatusBadRequest
} }
c.JSON(status, ApiResponse{Success: false, Message: msg, Data: nil}) c.JSON(status, APIResponse{Success: false, Message: msg, Data: nil})
} }

View File

@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
smslogic "spark-server/internal/module/sms/logic"
smsentity "spark-server/internal/module/sms/entity" smsentity "spark-server/internal/module/sms/entity"
smslogic "spark-server/internal/module/sms/logic"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n" "spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"

View File

@ -19,8 +19,8 @@ import (
metahandler "spark-server/internal/transport/http/handler/meta" metahandler "spark-server/internal/transport/http/handler/meta"
momenthandler "spark-server/internal/transport/http/handler/moment" momenthandler "spark-server/internal/transport/http/handler/moment"
musichandler "spark-server/internal/transport/http/handler/music" musichandler "spark-server/internal/transport/http/handler/music"
squarehandler "spark-server/internal/transport/http/handler/square"
smshandler "spark-server/internal/transport/http/handler/sms" smshandler "spark-server/internal/transport/http/handler/sms"
squarehandler "spark-server/internal/transport/http/handler/square"
userhandler "spark-server/internal/transport/http/handler/user" userhandler "spark-server/internal/transport/http/handler/user"
"spark-server/internal/transport/http/respond" "spark-server/internal/transport/http/respond"
) )

View File

@ -22,7 +22,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
ug := r.Group("/api/admin") ug := r.Group("/api/admin")
ug.Use(auth) ug.Use(auth)
{ {
ug.GET("/api-doc", h.ApiDoc) ug.GET("/api-doc", h.APIDoc)
ug.GET("/users", h.ListUsers) ug.GET("/users", h.ListUsers)
ug.GET("/users/by-email", h.UserByEmail) ug.GET("/users/by-email", h.UserByEmail)
ug.GET("/users/by-nickname", h.UserByNickname) 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) { 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("/ops-catalog", h.OpsCatalog)
g.GET("/im/stats", h.ImStats) g.GET("/im/stats", h.ImStats)

View File

@ -20,8 +20,8 @@ import (
msglogic "spark-server/internal/module/message/logic" msglogic "spark-server/internal/module/message/logic"
momentlogic "spark-server/internal/module/moment/logic" momentlogic "spark-server/internal/module/moment/logic"
musiclogic "spark-server/internal/module/music/logic" musiclogic "spark-server/internal/module/music/logic"
squarelogic "spark-server/internal/module/square/logic"
smslogic "spark-server/internal/module/sms/logic" smslogic "spark-server/internal/module/sms/logic"
squarelogic "spark-server/internal/module/square/logic"
synclogic "spark-server/internal/module/sync/logic" synclogic "spark-server/internal/module/sync/logic"
uploadlogic "spark-server/internal/module/upload/logic" uploadlogic "spark-server/internal/module/upload/logic"
userlogic "spark-server/internal/module/user/logic" userlogic "spark-server/internal/module/user/logic"