feat: add tenant entry registration APIs

This commit is contained in:
richjohnrambo
2026-08-24 15:08:47 +08:00
parent ed9413d923
commit e61058ec03
16 changed files with 1174 additions and 0 deletions
+7
View File
@@ -227,6 +227,13 @@ type ThirdPartyConfigAdapter interface {
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
// TenantEntryAdapter stores public tenant entry data before a user is authenticated.
type TenantEntryAdapter interface {
PublicUploadCreate(*t.PublicUpload) (*t.PublicUpload, error)
TenantApplicationCreate(*t.TenantApplication) (*t.TenantApplication, error)
TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
+3
View File
@@ -247,6 +247,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
if err = ensureIMTenantEntryControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error())
}
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
+2
View File
@@ -8,6 +8,8 @@
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
# - im_public_uploads, im_tenant_applications and im_tenant_entry_feedbacks
# for unauthenticated enterprise registration and tenant-entry feedback
#
# - Default tenant business database: tinode
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
+201
View File
@@ -0,0 +1,201 @@
//go:build mysql
// +build mysql
package mysql
import (
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) PublicUploadCreate(upload *t.PublicUpload) (*t.PublicUpload, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if upload.CreatedAt.IsZero() {
upload.CreatedAt = now
}
if upload.UploadedAt.IsZero() {
upload.UploadedAt = now
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_public_uploads
(file_id,purpose,mime_type,size,etag,location,expires_at,created_at,uploaded_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
upload.FileID, upload.Purpose, upload.MimeType, upload.Size, upload.ETag, upload.Location,
nullableTenantEntryTime(upload.ExpiresAt), upload.CreatedAt, upload.UploadedAt)
if err != nil {
return nil, err
}
return upload, nil
}
func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t.TenantApplication, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if application.Status == "" {
application.Status = t.TenantApplicationStatusReceived
}
if application.EstimatedReviewHour <= 0 {
application.EstimatedReviewHour = 24
}
if application.SubmittedAt.IsZero() {
application.SubmittedAt = now
}
application.CreatedAt = now
application.UpdatedAt = now
clientContext, _ := json.Marshal(application.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications
(application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID,
application.TenantCodeHint, string(clientContext), application.IdempotencyKey, application.Status,
application.EstimatedReviewHour, application.SubmittedAt, now, now)
if err != nil {
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
func (a *adapter) TenantEntryFeedbackCreate(feedback *t.TenantEntryFeedback) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if feedback.Status == "" {
feedback.Status = t.TenantEntryFeedbackReceived
}
if feedback.SubmittedAt.IsZero() {
feedback.SubmittedAt = now
}
feedback.CreatedAt = now
feedback.UpdatedAt = now
screenshots, _ := json.Marshal(feedback.ScreenshotFileIDs)
clientContext, _ := json.Marshal(feedback.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_entry_feedbacks
(feedback_id,description,contact,tenant_code_hint,screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
feedback.FeedbackID, feedback.Description, feedback.Contact, feedback.TenantCodeHint,
string(screenshots), string(clientContext), feedback.IdempotencyKey, feedback.Status,
feedback.SubmittedAt, now, now)
if err != nil {
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
func (a *adapter) tenantApplicationByIdempotency(idempotencyKey string) (*t.TenantApplication, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
ApplicationID string `db:"application_id"`
CreditCode string `db:"credit_code"`
EnterpriseEmail string `db:"enterprise_email"`
LicenseFileID string `db:"license_file_id"`
TenantCodeHint string `db:"tenant_code_hint"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
EstimatedReviewHour int `db:"estimated_review_hour"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT application_id,credit_code,enterprise_email,license_file_id,
tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at
FROM im_tenant_applications WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
app := &t.TenantApplication{
ApplicationID: row.ApplicationID,
CreditCode: row.CreditCode,
EnterpriseEmail: row.EnterpriseEmail,
LicenseFileID: row.LicenseFileID,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
EstimatedReviewHour: row.EstimatedReviewHour,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ClientContext, &app.ClientContext)
return app, nil
}
func (a *adapter) tenantEntryFeedbackByIdempotency(idempotencyKey string) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
FeedbackID string `db:"feedback_id"`
Description string `db:"description"`
Contact string `db:"contact"`
TenantCodeHint string `db:"tenant_code_hint"`
ScreenshotFileIDs []byte `db:"screenshot_file_ids"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT feedback_id,description,contact,tenant_code_hint,
screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at
FROM im_tenant_entry_feedbacks WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
feedback := &t.TenantEntryFeedback{
FeedbackID: row.FeedbackID,
Description: row.Description,
Contact: row.Contact,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ScreenshotFileIDs, &feedback.ScreenshotFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) controlDB() *sqlx.DB {
if a.registryDB != nil {
return a.registryDB
}
return a.db
}
func nullableTenantEntryTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
+72
View File
@@ -0,0 +1,72 @@
//go:build mysql
// +build mysql
package mysql
import "github.com/jmoiron/sqlx"
func ensureIMTenantEntryControlTables(db *sqlx.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
etag VARCHAR(128) NOT NULL DEFAULT '',
location VARCHAR(2048) NOT NULL DEFAULT '',
expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id),
INDEX idx_im_public_uploads_purpose(purpose, created_at),
INDEX idx_im_public_uploads_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
license_file_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
estimated_review_hour INT NOT NULL DEFAULT 24,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_applications_app(application_id),
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
INDEX idx_im_tenant_applications_credit(credit_code),
INDEX idx_im_tenant_applications_status(status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(255) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
screenshot_file_ids JSON,
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_feedback(feedback_id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
INDEX idx_im_tenant_entry_feedbacks_status(status, id),
INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,40 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureTenantEntryControlTablesStatements(t *testing.T) {
stmts := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key)
)`,
}
joined := strings.ToLower(strings.Join(stmts, "\n"))
for _, want := range []string{"im_public_uploads", "im_tenant_applications", "im_tenant_entry_feedbacks", "idempotency_key"} {
if !strings.Contains(joined, want) {
t.Fatalf("schema statement missing %s", want)
}
}
}