feat: assign IM user account numbers
This commit is contained in:
@@ -95,22 +95,23 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202", nil)
|
||||
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil)
|
||||
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone) LIKE ?", "u.id IN (?,?)"} {
|
||||
for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR)) LIKE ?", "u.id IN (?,?)", "u.account_no = ?"} {
|
||||
if !strings.Contains(query, clause) || !strings.Contains(countQuery, clause) {
|
||||
t.Fatalf("user query missing %q: %s", clause, query)
|
||||
}
|
||||
}
|
||||
want := []any{int64(101), int64(202), "%alice%"}
|
||||
want := []any{int64(101), int64(202), int64(1000), "%alice%"}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"AS avatar",
|
||||
"u.account_no AS display_no",
|
||||
"AS register_type",
|
||||
"im_registration_invite_uses",
|
||||
"im_fileuploads AS avatar",
|
||||
|
||||
+54
-13
@@ -9,6 +9,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -762,19 +763,21 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE im_users(
|
||||
id BIGINT NOT NULL,
|
||||
username VARCHAR(32) NOT NULL,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
stateat DATETIME(3),
|
||||
access JSON,
|
||||
lastseen DATETIME,
|
||||
useragent VARCHAR(255) DEFAULT '',
|
||||
phone VARCHAR(32),
|
||||
public JSON,
|
||||
trusted JSON,
|
||||
id BIGINT NOT NULL,
|
||||
account_no INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
username VARCHAR(32) NOT NULL,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
stateat DATETIME(3),
|
||||
access JSON,
|
||||
lastseen DATETIME,
|
||||
useragent VARCHAR(255) DEFAULT '',
|
||||
phone VARCHAR(32),
|
||||
public JSON,
|
||||
trusted JSON,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_users_account_no(account_no),
|
||||
UNIQUE INDEX users_username(username),
|
||||
UNIQUE INDEX users_phone(phone),
|
||||
INDEX users_state_stateat(state, stateat),
|
||||
@@ -2230,8 +2233,18 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
user.Phone = canonicalPhone
|
||||
phone = canonicalPhone
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
releaseAccountNoLock, err := lockUserAccountNoSequence(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseAccountNoLock()
|
||||
accountNo, err := nextUserAccountNo(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
decoded_uid,
|
||||
accountNo,
|
||||
user.Username,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
@@ -2260,6 +2273,34 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func lockUserAccountNoSequence(ctx context.Context, tx *sqlx.Tx) (func(), error) {
|
||||
var locked sql.NullInt64
|
||||
if err := tx.QueryRowContext(ctx, "SELECT GET_LOCK(CONCAT(DATABASE(), ':im_users_account_no'), 10)").Scan(&locked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !locked.Valid || locked.Int64 != 1 {
|
||||
return nil, fmt.Errorf("lock user account number sequence timeout")
|
||||
}
|
||||
return func() {
|
||||
_, _ = tx.ExecContext(ctx, "SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':im_users_account_no'))")
|
||||
}, nil
|
||||
}
|
||||
|
||||
func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (int64, error) {
|
||||
var current sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx, "SELECT account_no FROM im_users ORDER BY account_no DESC LIMIT 1 FOR UPDATE").Scan(¤t)
|
||||
if err == sql.ErrNoRows {
|
||||
return 1000, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !current.Valid || current.Int64 < 999 {
|
||||
return 1000, nil
|
||||
}
|
||||
return current.Int64 + 1, nil
|
||||
}
|
||||
|
||||
// TenantAuthAddRecord adds the user's Basic authentication record.
|
||||
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
|
||||
authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||
|
||||
@@ -289,8 +289,8 @@ func TestTenantAuthIsolation(t *testing.T) {
|
||||
userBID := userAID + 1
|
||||
for index, userID := range []int64{userAID, userBID} {
|
||||
tenantDB := quoteIdent(dbNames[index])
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+
|
||||
"VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) "+
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?)", userID, 1000+index, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)",
|
||||
@@ -360,9 +360,9 @@ func TestTenantUserSearchMatchesUsernamePhoneAndPublicAccount(t *testing.T) {
|
||||
{targetID, "potter", "+8615500000002", `{"fn":"Potter"}`},
|
||||
} {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
user.id, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil {
|
||||
`INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
user.id, user.id%1000000, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
|
||||
@@ -88,6 +88,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = ensureUserAccountNoSchema(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if profilesExist {
|
||||
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
|
||||
return err
|
||||
@@ -568,6 +571,39 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn) error {
|
||||
if exists, err := identityColumnExists(ctx, conn, "im_users", "account_no"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN account_no INT UNSIGNED NOT NULL DEFAULT 0 AFTER id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var start int64 = 999
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GREATEST(COALESCE(MAX(account_no), 999), 999) FROM im_users WHERE account_no>0").Scan(&start); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
UPDATE im_users AS u
|
||||
JOIN (
|
||||
SELECT ordered.id, (@im_user_account_no := @im_user_account_no + 1) AS next_no
|
||||
FROM (SELECT id FROM im_users WHERE account_no=0 ORDER BY createdat, id) AS ordered
|
||||
CROSS JOIN (SELECT @im_user_account_no := ?) AS vars
|
||||
) AS seq ON seq.id=u.id
|
||||
SET u.account_no=seq.next_no
|
||||
WHERE u.account_no=0`, start); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists, err := identityIndexExists(ctx, conn, "im_users", "uk_im_users_account_no"); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX uk_im_users_account_no ON im_users(account_no)"); err != nil {
|
||||
return fmt.Errorf("create unique user account number index: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) {
|
||||
var exists int
|
||||
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables
|
||||
|
||||
@@ -269,7 +269,7 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
|
||||
|
||||
func usersInternalQuerySpec() *internalQuerySpec {
|
||||
return &internalQuerySpec{
|
||||
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
|
||||
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
|
||||
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,
|
||||
COALESCE(
|
||||
NULLIF(avatar.location,''),
|
||||
@@ -288,7 +288,7 @@ func usersInternalQuerySpec() *internalQuerySpec {
|
||||
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
|
||||
DefaultSort: "ORDER BY u.id DESC",
|
||||
DetailKey: "u.id",
|
||||
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone)", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
|
||||
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -229,11 +229,18 @@ func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantI
|
||||
writeInternalQueryError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
displayNo := int64(0)
|
||||
if row, err := store.InternalAdminQueries.QueryOne(tenantID, "SELECT account_no FROM im_users WHERE id=?", []any{store.DecodeUid(created.Uid())}); err == nil {
|
||||
displayNo, _ = numericID(row["account_no"])
|
||||
} else {
|
||||
logs.Warn.Printf("internal user create account number query failed tenant:%d user:%d err:%v", tenantID, store.DecodeUid(created.Uid()), err)
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"user_id": store.DecodeUid(created.Uid()),
|
||||
"username": created.Username,
|
||||
"nickname": body.Nickname,
|
||||
"phone": created.Phone,
|
||||
"user_id": store.DecodeUid(created.Uid()),
|
||||
"display_no": displayNo,
|
||||
"username": created.Username,
|
||||
"nickname": body.Nickname,
|
||||
"phone": created.Phone,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user