feat: auto-friend invite bound users
This commit is contained in:
@@ -9,12 +9,18 @@ import (
|
|||||||
mysqldriver "github.com/go-sql-driver/mysql"
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ignoreSQLError struct {
|
||||||
|
sql string
|
||||||
|
code uint16
|
||||||
|
}
|
||||||
|
|
||||||
func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
|
func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
|
||||||
statements := []string{
|
statements := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS im_registration_invite_batches(
|
`CREATE TABLE IF NOT EXISTS im_registration_invite_batches(
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
tenant_id BIGINT NOT NULL,
|
tenant_id BIGINT NOT NULL,
|
||||||
quantity INT UNSIGNED NOT NULL,
|
quantity INT UNSIGNED NOT NULL,
|
||||||
|
max_uses_per_code INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
idempotency_key VARCHAR(128) NOT NULL,
|
idempotency_key VARCHAR(128) NOT NULL,
|
||||||
expires_at DATETIME(3) NOT NULL,
|
expires_at DATETIME(3) NOT NULL,
|
||||||
created_by BIGINT 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,
|
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
status VARCHAR(16) NOT NULL,
|
status VARCHAR(16) NOT NULL,
|
||||||
expires_at DATETIME(3) 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,
|
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
last_used_at DATETIME(3) NULL,
|
last_used_at DATETIME(3) NULL,
|
||||||
created_by BIGINT NOT NULL,
|
created_by BIGINT NOT NULL,
|
||||||
@@ -40,6 +49,7 @@ func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
|
|||||||
updated_at DATETIME(3) NOT NULL,
|
updated_at DATETIME(3) NOT NULL,
|
||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code),
|
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_status_expires(tenant_id,status,expires_at),
|
||||||
INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id),
|
INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id),
|
||||||
INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at)
|
INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at)
|
||||||
@@ -50,6 +60,29 @@ func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package mysql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -60,8 +61,8 @@ func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.Registra
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
_, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes
|
_, 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)
|
(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,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt,
|
VALUES(?,?,?,?,?,?,1,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt,
|
||||||
code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt)
|
code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt)
|
||||||
if isDupe(err) {
|
if isDupe(err) {
|
||||||
return t.ErrDuplicate
|
return t.ErrDuplicate
|
||||||
@@ -128,8 +129,8 @@ func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.Re
|
|||||||
codes[i].Remark = batch.Remark
|
codes[i].Remark = batch.Remark
|
||||||
codes[i].CreatedAt, codes[i].UpdatedAt = now, now
|
codes[i].CreatedAt, codes[i].UpdatedAt = now, now
|
||||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes
|
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)
|
(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,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status,
|
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 {
|
codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil {
|
||||||
if isDupe(err) {
|
if isDupe(err) {
|
||||||
return t.ErrDuplicate
|
return t.ErrDuplicate
|
||||||
@@ -156,7 +157,7 @@ func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string)
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
var code t.RegistrationInviteCode
|
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
|
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
|
FROM im_registration_invite_codes AS c
|
||||||
WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value)
|
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)
|
queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset)
|
||||||
var items []t.RegistrationInviteCode
|
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
|
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
|
FROM im_registration_invite_codes AS c
|
||||||
WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
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
|
return t.ErrMalformed
|
||||||
}
|
}
|
||||||
var row struct {
|
var row struct {
|
||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
Status t.RegistrationInviteStatus `db:"status"`
|
Status t.RegistrationInviteStatus `db:"status"`
|
||||||
ExpiresAt time.Time `db:"expires_at"`
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
MaxUses int `db:"max_uses"`
|
||||||
|
BoundUserID *int64 `db:"bound_user_id"`
|
||||||
}
|
}
|
||||||
ctx, cancel := a.getContext()
|
ctx, cancel := a.getContext()
|
||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
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)
|
FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return t.ErrInviteInvalid
|
return t.ErrInviteInvalid
|
||||||
@@ -384,6 +387,16 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
|
|||||||
_ = tx.Rollback()
|
_ = 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()
|
useID, err := nextSnowflakeID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -403,9 +416,17 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
|
|||||||
return err
|
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))
|
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{
|
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,
|
"request_id": requestID, "used_at": now,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -413,6 +434,28 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
|
|||||||
return tx.Commit()
|
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
|
// TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the
|
||||||
// control database. The overwrite is idempotent, so outbox retries are safe.
|
// control database. The overwrite is idempotent, so outbox retries are safe.
|
||||||
func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error {
|
func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error {
|
||||||
|
|||||||
@@ -37,18 +37,20 @@ type RegistrationInviteBatch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationInviteCode struct {
|
type RegistrationInviteCode struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ID int64 `db:"id" json:"id"`
|
||||||
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
||||||
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
|
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
|
||||||
Code string `db:"code" json:"code"`
|
Code string `db:"code" json:"code"`
|
||||||
Status RegistrationInviteStatus `db:"status" json:"status"`
|
Status RegistrationInviteStatus `db:"status" json:"status"`
|
||||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
MaxUses int `db:"max_uses" json:"max_uses"`
|
||||||
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
|
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||||
CreatedBy int64 `db:"created_by" json:"created_by"`
|
BoundUserID *int64 `db:"bound_user_id" json:"bound_user_id,omitempty"`
|
||||||
Remark string `db:"remark" json:"remark,omitempty"`
|
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedBy int64 `db:"created_by" json:"created_by"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
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 {
|
type RegistrationInviteListOptions struct {
|
||||||
|
|||||||
@@ -452,6 +452,9 @@ func validateRegistrationInvite(tenantID types.TenantID, code string, now time.T
|
|||||||
if !now.Before(invite.ExpiresAt) {
|
if !now.Before(invite.ExpiresAt) {
|
||||||
return types.ErrInviteExpired
|
return types.ErrInviteExpired
|
||||||
}
|
}
|
||||||
|
if invite.MaxUses > 0 && invite.UsageCount >= invite.MaxUses {
|
||||||
|
return types.ErrInviteInvalid
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return types.ErrInviteInvalid
|
return types.ErrInviteInvalid
|
||||||
|
|||||||
Reference in New Issue
Block a user