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) {
|
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(), "")
|
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !strings.Contains(query, clause) || !strings.Contains(countQuery, clause) {
|
||||||
t.Fatalf("user query missing %q: %s", clause, query)
|
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) {
|
if !reflect.DeepEqual(args, want) {
|
||||||
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
|
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
|
||||||
}
|
}
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
"AS avatar",
|
"AS avatar",
|
||||||
|
"u.account_no AS display_no",
|
||||||
"AS register_type",
|
"AS register_type",
|
||||||
"im_registration_invite_uses",
|
"im_registration_invite_uses",
|
||||||
"im_fileuploads AS avatar",
|
"im_fileuploads AS avatar",
|
||||||
|
|||||||
+54
-13
@@ -9,6 +9,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -762,19 +763,21 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
|
|
||||||
if _, err = tx.Exec(
|
if _, err = tx.Exec(
|
||||||
`CREATE TABLE im_users(
|
`CREATE TABLE im_users(
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
username VARCHAR(32) NOT NULL,
|
account_no INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
createdat DATETIME(3) NOT NULL,
|
username VARCHAR(32) NOT NULL,
|
||||||
updatedat DATETIME(3) NOT NULL,
|
createdat DATETIME(3) NOT NULL,
|
||||||
state SMALLINT NOT NULL DEFAULT 0,
|
updatedat DATETIME(3) NOT NULL,
|
||||||
stateat DATETIME(3),
|
state SMALLINT NOT NULL DEFAULT 0,
|
||||||
access JSON,
|
stateat DATETIME(3),
|
||||||
lastseen DATETIME,
|
access JSON,
|
||||||
useragent VARCHAR(255) DEFAULT '',
|
lastseen DATETIME,
|
||||||
phone VARCHAR(32),
|
useragent VARCHAR(255) DEFAULT '',
|
||||||
public JSON,
|
phone VARCHAR(32),
|
||||||
trusted JSON,
|
public JSON,
|
||||||
|
trusted JSON,
|
||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE INDEX uk_im_users_account_no(account_no),
|
||||||
UNIQUE INDEX users_username(username),
|
UNIQUE INDEX users_username(username),
|
||||||
UNIQUE INDEX users_phone(phone),
|
UNIQUE INDEX users_phone(phone),
|
||||||
INDEX users_state_stateat(state, stateat),
|
INDEX users_state_stateat(state, stateat),
|
||||||
@@ -2230,8 +2233,18 @@ func (a *adapter) UserCreate(user *t.User) error {
|
|||||||
user.Phone = canonicalPhone
|
user.Phone = canonicalPhone
|
||||||
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,
|
decoded_uid,
|
||||||
|
accountNo,
|
||||||
user.Username,
|
user.Username,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.UpdatedAt,
|
user.UpdatedAt,
|
||||||
@@ -2260,6 +2273,34 @@ func (a *adapter) UserCreate(user *t.User) error {
|
|||||||
return tx.Commit()
|
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.
|
// TenantAuthAddRecord adds the user's Basic authentication record.
|
||||||
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
|
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
|
||||||
authLvl auth.Level, secret []byte, expires time.Time) error {
|
authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||||
|
|||||||
@@ -289,8 +289,8 @@ func TestTenantAuthIsolation(t *testing.T) {
|
|||||||
userBID := userAID + 1
|
userBID := userAID + 1
|
||||||
for index, userID := range []int64{userAID, userBID} {
|
for index, userID := range []int64{userAID, userBID} {
|
||||||
tenantDB := quoteIdent(dbNames[index])
|
tenantDB := quoteIdent(dbNames[index])
|
||||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+
|
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) "+
|
||||||
"VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
"VALUES(?,?,?,?,?,?,?,?,?,?)", userID, 1000+index, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)",
|
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"}`},
|
{targetID, "potter", "+8615500000002", `{"fn":"Potter"}`},
|
||||||
} {
|
} {
|
||||||
if _, err := db.Exec(
|
if _, err := db.Exec(
|
||||||
`INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted)
|
`INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||||
user.id, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil {
|
user.id, user.id%1000000, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(
|
if _, err := db.Exec(
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err = ensureUserAccountNoSchema(ctx, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if profilesExist {
|
if profilesExist {
|
||||||
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
|
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -568,6 +571,39 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho
|
|||||||
return out, nil
|
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) {
|
func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) {
|
||||||
var exists int
|
var exists int
|
||||||
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables
|
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 {
|
func usersInternalQuerySpec() *internalQuerySpec {
|
||||||
return &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,
|
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(avatar.location,''),
|
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]+$'",
|
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",
|
DefaultSort: "ORDER BY u.id DESC",
|
||||||
DetailKey: "u.id",
|
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)
|
writeInternalQueryError(wrt, req, err)
|
||||||
return
|
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{
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
"user_id": store.DecodeUid(created.Uid()),
|
"user_id": store.DecodeUid(created.Uid()),
|
||||||
"username": created.Username,
|
"display_no": displayNo,
|
||||||
"nickname": body.Nickname,
|
"username": created.Username,
|
||||||
"phone": created.Phone,
|
"nickname": body.Nickname,
|
||||||
|
"phone": created.Phone,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user