From b9dc56eccf3a942be29a3be8a9d08fc760f57222 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 11 Sep 2026 10:46:20 +0800 Subject: [PATCH] feat: auto-friend invite bound users --- server/db/mysql/registration_invite_schema.go | 33 ++++++++++ server/db/mysql/registration_invites.go | 65 +++++++++++++++---- server/store/types/registration_invite.go | 26 ++++---- server/user.go | 3 + 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/server/db/mysql/registration_invite_schema.go b/server/db/mysql/registration_invite_schema.go index 46133a6..9d19197 100644 --- a/server/db/mysql/registration_invite_schema.go +++ b/server/db/mysql/registration_invite_schema.go @@ -9,12 +9,18 @@ import ( mysqldriver "github.com/go-sql-driver/mysql" ) +type ignoreSQLError struct { + sql string + code uint16 +} + func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error { statements := []string{ `CREATE TABLE IF NOT EXISTS im_registration_invite_batches( id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, quantity INT UNSIGNED NOT NULL, + max_uses_per_code INT UNSIGNED NOT NULL DEFAULT 0, idempotency_key VARCHAR(128) NOT NULL, expires_at DATETIME(3) NOT NULL, created_by BIGINT NOT NULL, @@ -32,6 +38,9 @@ func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error { code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, status VARCHAR(16) NOT NULL, expires_at DATETIME(3) NOT NULL, + max_uses INT UNSIGNED NOT NULL DEFAULT 0, + bound_user_id BIGINT NULL, + bound_user_label VARCHAR(128) NOT NULL DEFAULT '', usage_count INT UNSIGNED NOT NULL DEFAULT 0, last_used_at DATETIME(3) NULL, created_by BIGINT NOT NULL, @@ -40,6 +49,7 @@ func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error { updated_at DATETIME(3) NOT NULL, PRIMARY KEY(id), UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code), + INDEX idx_im_registration_invite_tenant_bound_user(tenant_id,bound_user_id), INDEX idx_im_registration_invite_tenant_status_expires(tenant_id,status,expires_at), INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id), INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at) @@ -50,6 +60,29 @@ func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error { return err } } + alterStatements := []ignoreSQLError{ + {`ALTER TABLE im_registration_invite_codes ADD COLUMN max_uses INT UNSIGNED NOT NULL DEFAULT 0 AFTER expires_at`, 1060}, + {`ALTER TABLE im_registration_invite_codes ADD COLUMN bound_user_id BIGINT NULL AFTER max_uses`, 1060}, + {`ALTER TABLE im_registration_invite_codes ADD COLUMN bound_user_label VARCHAR(128) NOT NULL DEFAULT '' AFTER bound_user_id`, 1060}, + {`ALTER TABLE im_registration_invite_codes ADD INDEX idx_im_registration_invite_tenant_bound_user(tenant_id,bound_user_id)`, 1061}, + {`ALTER TABLE im_registration_invite_batches ADD COLUMN max_uses_per_code INT UNSIGNED NOT NULL DEFAULT 0 AFTER quantity`, 1060}, + } + for _, stmt := range alterStatements { + if err := execIgnoreMySQLError(exec, stmt.sql, stmt.code); err != nil { + return err + } + } + return nil +} + +func execIgnoreMySQLError(exec sqlExecutor, statement string, code uint16) error { + if _, err := exec.Exec(statement); err != nil { + var mysqlErr *mysqldriver.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == code { + return nil + } + return err + } return nil } diff --git a/server/db/mysql/registration_invites.go b/server/db/mysql/registration_invites.go index e9e3fb7..3bba7ab 100644 --- a/server/db/mysql/registration_invites.go +++ b/server/db/mysql/registration_invites.go @@ -5,6 +5,7 @@ package mysql import ( "database/sql" + "strconv" "strings" "time" @@ -60,8 +61,8 @@ func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.Registra defer cancel() } _, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes - (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) - VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt, + (id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,1,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt, code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt) if isDupe(err) { return t.ErrDuplicate @@ -128,8 +129,8 @@ func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.Re codes[i].Remark = batch.Remark codes[i].CreatedAt, codes[i].UpdatedAt = now, now if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes - (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) - VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status, + (id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status, codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil { if isDupe(err) { return t.ErrDuplicate @@ -156,7 +157,7 @@ func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string) defer cancel() } var code t.RegistrationInviteCode - err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at, + err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,c.max_uses,c.bound_user_id, c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at FROM im_registration_invite_codes AS c WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value) @@ -227,7 +228,7 @@ func (a *adapter) TenantRegistrationInviteList(id t.TenantID, opts t.Registratio } queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset) var items []t.RegistrationInviteCode - err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at, + err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,c.max_uses,c.bound_user_id, c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at FROM im_registration_invite_codes AS c WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...) @@ -345,15 +346,17 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, return t.ErrMalformed } var row struct { - ID int64 `db:"id"` - Status t.RegistrationInviteStatus `db:"status"` - ExpiresAt time.Time `db:"expires_at"` + ID int64 `db:"id"` + Status t.RegistrationInviteStatus `db:"status"` + ExpiresAt time.Time `db:"expires_at"` + MaxUses int `db:"max_uses"` + BoundUserID *int64 `db:"bound_user_id"` } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at + err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at,max_uses,bound_user_id FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value) if err == sql.ErrNoRows { return t.ErrInviteInvalid @@ -384,6 +387,16 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, _ = tx.Rollback() } }() + if row.MaxUses > 0 { + var usedCount int + if err = tx.GetContext(txCtx, &usedCount, + "SELECT COUNT(*) FROM im_registration_invite_uses WHERE invite_code_id=?", row.ID); err != nil { + return err + } + if usedCount >= row.MaxUses { + return t.ErrInviteInvalid + } + } useID, err := nextSnowflakeID() if err != nil { return err @@ -403,9 +416,17 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, return err } } + if row.BoundUserID != nil && *row.BoundUserID > 0 { + boundUID := t.ParseUserId(strconv.FormatInt(*row.BoundUserID, 10)) + if !boundUID.IsZero() && boundUID != uid { + if err = upsertFriendshipPairTx(tx, uid, boundUID, uid.P2PName(boundUID), now); err != nil { + return err + } + } + } eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid)) if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{ - "tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid), + "tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid), "bound_user_id": row.BoundUserID, "request_id": requestID, "used_at": now, }); err != nil { return err @@ -413,6 +434,28 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, return tx.Commit() } +func upsertFriendshipPairTx(tx *sqlx.Tx, uid1, uid2 t.Uid, topic string, now time.Time) error { + if uid1.IsZero() || uid2.IsZero() || uid1 == uid2 { + return t.ErrMalformed + } + if topic == "" { + topic = uid1.P2PName(uid2) + } + for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} { + friendshipID, err := nextSnowflakeID() + if err != nil { + return err + } + if _, err = tx.Exec( + "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ + "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", + friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { + return err + } + } + return nil +} + // TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the // control database. The overwrite is idempotent, so outbox retries are safe. func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error { diff --git a/server/store/types/registration_invite.go b/server/store/types/registration_invite.go index 6c64779..2193b28 100644 --- a/server/store/types/registration_invite.go +++ b/server/store/types/registration_invite.go @@ -37,18 +37,20 @@ type RegistrationInviteBatch struct { } type RegistrationInviteCode struct { - ID int64 `db:"id" json:"id"` - TenantID TenantID `db:"tenant_id" json:"tenant_id"` - BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"` - Code string `db:"code" json:"code"` - Status RegistrationInviteStatus `db:"status" json:"status"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - UsageCount int `db:"usage_count" json:"usage_count"` - LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"` - CreatedBy int64 `db:"created_by" json:"created_by"` - Remark string `db:"remark" json:"remark,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + TenantID TenantID `db:"tenant_id" json:"tenant_id"` + BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"` + Code string `db:"code" json:"code"` + Status RegistrationInviteStatus `db:"status" json:"status"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + MaxUses int `db:"max_uses" json:"max_uses"` + UsageCount int `db:"usage_count" json:"usage_count"` + BoundUserID *int64 `db:"bound_user_id" json:"bound_user_id,omitempty"` + LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"` + CreatedBy int64 `db:"created_by" json:"created_by"` + Remark string `db:"remark" json:"remark,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type RegistrationInviteListOptions struct { diff --git a/server/user.go b/server/user.go index f9e4a87..372cc55 100644 --- a/server/user.go +++ b/server/user.go @@ -452,6 +452,9 @@ func validateRegistrationInvite(tenantID types.TenantID, code string, now time.T if !now.Before(invite.ExpiresAt) { return types.ErrInviteExpired } + if invite.MaxUses > 0 && invite.UsageCount >= invite.MaxUses { + return types.ErrInviteInvalid + } return nil default: return types.ErrInviteInvalid