diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index 193c051..5206660 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -58,13 +58,12 @@ Do **not** move `infrastructure` into `pkg` (or a public repo-root `pkg/`). ## IDs -Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys. +Not every PK is Snowflake. Do not AUTO_INCREMENT Snowflake business ids (`users.id`, `messages.id`, `conversations.id`). | Kind | Examples | Rule | |------|----------|------| | Snowflake | `users.id` (uid), `messages.id` (MsgId), `conversations.id` | Time-ordered business IDs. One `id.Generator` per process; `snowflake_worker_id` unique per process. Clock rewind fails issuance for the whole process. | -| AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号), `friend_relations.id` | Internal row id, or sequential public 群号. Friend edges are directed (`user_id`→`friend_id`); UNIQUE `(user_id, friend_id)`. Business chat cursor is still Snowflake `conversations.id`. | -| Natural key | `user_emails.email`, P2P `(user_small, user_big)`, members, sync cursors | Composite / natural PK. No extra surrogate id. | +| AUTO_INCREMENT | `groups.id` (群号); row id on `user_devices`, `friend_relations`, `user_login_logs`, `user_emails`, `p2p_conversations`, `conversation_members`, `group_members`, `device_sync_cursors`, `message_deletes`, `moment_audience`, `moment_likes`, `moment_settings`, `moment_hides` | Table-local row id. Business uniqueness stays UNIQUE (email, P2P pair, members, cursors). Do not AUTO_INCREMENT Snowflake columns. | `device_id` is **client-generated**, stored on the device, written to JWT `did`. The server does not allocate it. `uid` in JSON **is** `users.id`; do not add a second uid column. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 5baacec..121d28b 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -128,6 +128,7 @@ func ignorableDDL(err error) bool { 1051, // unknown table 1060, // duplicate column 1061, // duplicate key name + 1091, // can't drop; check that column/key exists 1146: // table doesn't exist return true default: diff --git a/docs/architecture-v3.md b/docs/architecture-v3.md index 182fda4..f578b90 100644 --- a/docs/architecture-v3.md +++ b/docs/architecture-v3.md @@ -441,20 +441,22 @@ CREATE TABLE conversations ( -- 两人永远同一条会话,学 BFF 小 uid : 大 uid CREATE TABLE p2p_conversations ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_small BIGINT NOT NULL, user_big BIGINT NOT NULL, conversation_id BIGINT NOT NULL, - PRIMARY KEY (user_small, user_big), + UNIQUE KEY uk_p2p_users (user_small, user_big), UNIQUE KEY uk_conversation (conversation_id) ); CREATE TABLE conversation_members ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, conversation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, last_read_msg_id BIGINT NOT NULL DEFAULT 0, joined_at DATETIME(3) NOT NULL, muted TINYINT NOT NULL DEFAULT 0, - PRIMARY KEY (conversation_id, user_id), + UNIQUE KEY uk_conversation_members (conversation_id, user_id), KEY idx_user_id (user_id) ); ``` @@ -481,12 +483,13 @@ CREATE TABLE mailbox_events ( ); CREATE TABLE device_sync_cursors ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, device_id BIGINT NOT NULL, mailbox_seq BIGINT NOT NULL DEFAULT 0, dialog_score BIGINT NOT NULL DEFAULT 0, -- Redis ZSET score 游标 updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (user_id, device_id) + UNIQUE KEY uk_user_device (user_id, device_id) ); ``` diff --git a/internal/module/conversation/model/conversation_member.go b/internal/module/conversation/model/conversation_member.go index ae84452..ad0d414 100644 --- a/internal/module/conversation/model/conversation_member.go +++ b/internal/module/conversation/model/conversation_member.go @@ -10,8 +10,9 @@ import ( // ConversationMember maps table conversation_members. type ConversationMember struct { - ConversationID int64 `gorm:"column:conversation_id;primaryKey;comment:会话 id"` - UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (conversation_id, user_id)"` + ConversationID int64 `gorm:"column:conversation_id;comment:会话 id"` + UserID int64 `gorm:"column:user_id;comment:成员 user_id"` LastReadMsgID int64 `gorm:"column:last_read_msg_id;comment:该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建"` JoinedAt time.Time `gorm:"column:joined_at;comment:加入会话时间"` Muted int `gorm:"column:muted;comment:0=未静音 1=免打扰"` diff --git a/internal/module/conversation/model/p2p_conversation.go b/internal/module/conversation/model/p2p_conversation.go index c377592..b8efb12 100644 --- a/internal/module/conversation/model/p2p_conversation.go +++ b/internal/module/conversation/model/p2p_conversation.go @@ -2,8 +2,9 @@ package model // P2PConversation maps table p2p_conversations. type P2PConversation struct { - UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"` - UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_small, user_big)"` + UserSmall int64 `gorm:"column:user_small;comment:两人中较小的 user_id,保证同一对用户只有一行"` + UserBig int64 `gorm:"column:user_big;comment:两人中较大的 user_id"` ConversationID int64 `gorm:"column:conversation_id;comment:对应 conversations.id"` } diff --git a/internal/module/group/model/group_member.go b/internal/module/group/model/group_member.go index 4a4a6d0..9141120 100644 --- a/internal/module/group/model/group_member.go +++ b/internal/module/group/model/group_member.go @@ -10,8 +10,9 @@ import ( // GroupMember maps table group_members. type GroupMember struct { - GroupID int64 `gorm:"column:group_id;primaryKey;comment:群号,groups.id"` - UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (group_id, user_id)"` + GroupID int64 `gorm:"column:group_id;comment:群号,groups.id"` + UserID int64 `gorm:"column:user_id;comment:成员 user_id"` Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"` Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"` Remark string `gorm:"column:remark;comment:成员对该群的备注,仅自己可见"` diff --git a/internal/module/message/model/message_delete.go b/internal/module/message/model/message_delete.go index a29376c..147262b 100644 --- a/internal/module/message/model/message_delete.go +++ b/internal/module/message/model/message_delete.go @@ -2,10 +2,11 @@ package model import "time" -// MessageDelete maps table message_deletes. PK is (user_id, msg_id). +// MessageDelete maps table message_deletes. Business unique key is (user_id, msg_id). type MessageDelete struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:删除者 user_id,仅该用户历史不可见"` - MsgID int64 `gorm:"column:msg_id;primaryKey;comment:被删除的 Snowflake MsgId"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, msg_id)"` + UserID int64 `gorm:"column:user_id;comment:删除者 user_id,仅该用户历史不可见"` + MsgID int64 `gorm:"column:msg_id;comment:被删除的 Snowflake MsgId"` ConversationID int64 `gorm:"column:conversation_id;comment:所属会话,便于按会话过滤"` CreatedAt time.Time `gorm:"column:created_at;comment:删除时间"` } diff --git a/internal/module/moment/model/row.go b/internal/module/moment/model/row.go index fd59374..e93d704 100644 --- a/internal/module/moment/model/row.go +++ b/internal/module/moment/model/row.go @@ -56,16 +56,18 @@ func postFrom(p *entity.Post) *Post { // Audience maps table moment_audience. type Audience struct { - PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"` - PeerID int64 `gorm:"column:peer_id;primaryKey;comment:白名单能看 / 黑名单不能看 的好友 uid"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (post_id, peer_id)"` + PostID int64 `gorm:"column:post_id;comment:帖 id"` + PeerID int64 `gorm:"column:peer_id;comment:白名单能看 / 黑名单不能看 的好友 uid"` } func (Audience) TableName() string { return "moment_audience" } // Like maps table moment_likes. type Like struct { - PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"` - UserID int64 `gorm:"column:user_id;primaryKey;comment:点赞人"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (post_id, user_id)"` + PostID int64 `gorm:"column:post_id;comment:帖 id"` + UserID int64 `gorm:"column:user_id;comment:点赞人"` CreatedAt time.Time `gorm:"column:created_at;comment:点赞时间"` } @@ -146,7 +148,8 @@ func (r *Notice) Entity() *entity.Notice { // Setting maps table moment_settings. type Setting struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 users.id"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 user_id"` + UserID int64 `gorm:"column:user_id;comment:用户 users.id"` CoverKey string `gorm:"column:cover_key;comment:封面 object_key,空表示未设"` VisibleDays int16 `gorm:"column:visible_days;comment:允许朋友查看范围:0全部 3/30/180 天"` LastReadPostID int64 `gorm:"column:last_read_post_id;comment:发现页红点水位,只增不减"` @@ -180,8 +183,9 @@ func settingFrom(s *entity.Settings) *Setting { // Hide maps table moment_hides. type Hide struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:设置方"` - PeerID int64 `gorm:"column:peer_id;primaryKey;comment:对方"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, peer_id)"` + UserID int64 `gorm:"column:user_id;comment:设置方"` + PeerID int64 `gorm:"column:peer_id;comment:对方"` HideHim bool `gorm:"column:hide_him;comment:1=不看他(Feed 去掉对方)"` HideMe bool `gorm:"column:hide_me;comment:1=不让他看"` } diff --git a/internal/module/moment/model/write.go b/internal/module/moment/model/write.go index f9ba8c1..d3b3965 100644 --- a/internal/module/moment/model/write.go +++ b/internal/module/moment/model/write.go @@ -79,7 +79,10 @@ func (m *Repo) AddLike(ctx context.Context, like *entity.Like) (bool, error) { } created := false err = db.Transaction(func(tx *gorm.DB) error { - res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row) + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "post_id"}, {Name: "user_id"}}, + DoNothing: true, + }).Create(&row) if res.Error != nil { return mapDBErr(res.Error) } @@ -197,7 +200,12 @@ func (m *Repo) UpsertSettings(ctx context.Context, s *entity.Settings) error { if row.UpdatedAt.IsZero() { row.UpdatedAt = time.Now() } - return mapDBErr(db.Save(row).Error) + return mapDBErr(db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "cover_key", "visible_days", "last_read_post_id", "updated_at", + }), + }).Create(row).Error) } func (m *Repo) AdvanceRead(ctx context.Context, userID, postID int64) error { diff --git a/internal/module/sync/model/device_sync_cursor.go b/internal/module/sync/model/device_sync_cursor.go index c07da11..4762288 100644 --- a/internal/module/sync/model/device_sync_cursor.go +++ b/internal/module/sync/model/device_sync_cursor.go @@ -4,8 +4,9 @@ import "time" // DeviceSyncCursor maps table device_sync_cursors. type DeviceSyncCursor struct { - UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"` - DeviceID int64 `gorm:"column:device_id;primaryKey;comment:设备 id"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"` + UserID int64 `gorm:"column:user_id;comment:用户 id"` + DeviceID int64 `gorm:"column:device_id;comment:设备 id"` MailboxSeq int64 `gorm:"column:mailbox_seq;comment:该设备已同步到的 mailbox_events.seq"` DialogScore int64 `gorm:"column:dialog_score;comment:会话列表 Redis ZSET 游标"` UpdatedAt time.Time `gorm:"column:updated_at;comment:游标更新时间"` diff --git a/internal/module/sync/model/store.go b/internal/module/sync/model/store.go index d44bf28..55b33de 100644 --- a/internal/module/sync/model/store.go +++ b/internal/module/sync/model/store.go @@ -7,6 +7,7 @@ import ( mysqldriver "github.com/go-sql-driver/mysql" "gorm.io/gorm" + "gorm.io/gorm/clause" "spark-server/internal/module/sync/entity" "spark-server/internal/pkg/errs" @@ -119,5 +120,8 @@ func (s *Store) SaveCursor(ctx context.Context, userID int64, deviceID uint64, m UserID: userID, DeviceID: int64(deviceID), MailboxSeq: mailboxSeq, DialogScore: dialogScore, UpdatedAt: time.Now(), } - return mapDBErr(db.Save(&row).Error) + return mapDBErr(db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"mailbox_seq", "dialog_score", "updated_at"}), + }).Create(&row).Error) } diff --git a/internal/module/user/model/user_email.go b/internal/module/user/model/user_email.go index 519217a..8b815b8 100644 --- a/internal/module/user/model/user_email.go +++ b/internal/module/user/model/user_email.go @@ -11,7 +11,8 @@ import ( // UserEmail maps table user_emails: one normalized email to one user. type UserEmail struct { - Email string `gorm:"column:email;primaryKey;comment:规范化后的小写邮箱,登录账号"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 email"` + Email string `gorm:"column:email;comment:规范化后的小写邮箱,登录账号"` UserID int64 `gorm:"column:user_id;comment:绑定的 users.id,一对一"` CreatedAt time.Time `gorm:"column:created_at;comment:绑定时间"` } diff --git a/migrations/0016_row_autoincrement_id.sql b/migrations/0016_row_autoincrement_id.sql new file mode 100644 index 0000000..6680a4d --- /dev/null +++ b/migrations/0016_row_autoincrement_id.sql @@ -0,0 +1,63 @@ +-- Junction / mapping tables get a table-local AUTO_INCREMENT row id. +-- Business uniqueness stays UNIQUE (same columns as the old composite PK). +-- Snowflake PKs (users / messages / conversations / moments posts) are unchanged. + +ALTER TABLE user_emails + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 email' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_email (email); + +ALTER TABLE p2p_conversations + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_small, user_big)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_p2p_users (user_small, user_big); + +ALTER TABLE conversation_members + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (conversation_id, user_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_conversation_members (conversation_id, user_id); + +ALTER TABLE device_sync_cursors + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_user_device (user_id, device_id); + +ALTER TABLE group_members + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (group_id, user_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_group_members (group_id, user_id); + +ALTER TABLE message_deletes + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, msg_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_user_msg (user_id, msg_id); + +ALTER TABLE moment_audience + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (post_id, peer_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_post_peer (post_id, peer_id); + +ALTER TABLE moment_likes + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (post_id, user_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_post_user (post_id, user_id); + +ALTER TABLE moment_settings + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 user_id' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_user_id (user_id); + +ALTER TABLE moment_hides + DROP PRIMARY KEY, + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, peer_id)' FIRST, + ADD PRIMARY KEY (id), + ADD UNIQUE KEY uk_user_peer (user_id, peer_id);