feat: initialize tenant workbench in shared database

This commit is contained in:
richjohnrambo
2026-09-23 17:04:01 +08:00
parent c8413f82d9
commit 86c5486cc4
11 changed files with 187 additions and 92 deletions
+11 -1
View File
@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
)
type Config struct {
@@ -88,7 +90,7 @@ func defaultConfig() *Config {
return &Config{
Listen: ":6061",
MySQL: MySQLConfig{
DSN: "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local",
DSN: "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local",
},
Redis: RedisConfig{
Addr: "127.0.0.1:6379",
@@ -186,6 +188,14 @@ func (c *Config) validate() error {
if strings.TrimSpace(c.MySQL.DSN) == "" {
return errors.New("mysql dsn is required")
}
mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://")
parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN)
if err != nil {
return fmt.Errorf("mysql dsn is invalid: %w", err)
}
if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") {
return errors.New("mysql database tinode is deprecated; use meeting_im")
}
if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev {
return errors.New("redis addr is required")
}
+26
View File
@@ -0,0 +1,26 @@
package config
import (
"strings"
"testing"
)
func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) {
cfg := defaultConfig()
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true"
err := cfg.validate()
if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") {
t.Fatalf("validate() error = %v, want deprecated database error", err)
}
}
func TestDefaultConfigUsesMeetingIMDatabase(t *testing.T) {
cfg := defaultConfig()
if err := cfg.validate(); err != nil {
t.Fatalf("default config validation failed: %v", err)
}
if !strings.Contains(cfg.MySQL.DSN, "/meeting_im?") {
t.Fatalf("default mysql dsn = %q, want meeting_im database", cfg.MySQL.DSN)
}
}
+36
View File
@@ -10,6 +10,42 @@ import (
"git.118im.com/im_team/im_workbench/internal/store"
)
func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r)
return
}
var body struct {
TenantID int64 `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
}
if !decodeJSON(w, r, &body) {
return
}
if body.TenantID <= 0 {
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"})
return
}
st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode))
if err != nil {
writeStoreError(w, r, err)
return
}
result, err := st.InitializeTenantDefaults(r.Context())
if err != nil {
writeStoreError(w, r, err)
return
}
writeSuccess(w, r, map[string]any{
"tenant_id": body.TenantID,
"workbench_apps": result.WorkbenchApps,
"attendance_rules": result.AttendanceRules,
"attendance_shifts": result.AttendanceShifts,
"mailbox_configs": result.MailboxConfigs,
"feedback_configs": result.FeedbackConfigs,
})
}
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
st, ok := a.internalAdminStore(w, r)
if !ok {
+1
View File
@@ -68,6 +68,7 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/internal/admin/workbench/organization/members", a.withInternalAdminAuth(a.adminOrganizationMembers))
mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig))
mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig))
mux.HandleFunc("/internal/admin/workbench/tenants/initialize", a.withInternalAdminAuth(a.adminInitializeTenant))
}
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
+8 -8
View File
@@ -231,10 +231,10 @@ func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int,
func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) {
var cfg AdminMailboxConfig
if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil {
if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
if errorsIsNoRows(err) {
now := time.Now().UTC().Round(time.Millisecond)
return &AdminMailboxConfig{ID: 1, Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求,匿名提交将按企业政策保护展示身份。", NoticeUpdatedAt: now, UpdatedAt: now}, nil
return &AdminMailboxConfig{Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", NoticeUpdatedAt: now, UpdatedAt: now}, nil
}
return nil, err
}
@@ -243,8 +243,8 @@ func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, er
func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error {
now := time.Now().UTC().Round(time.Millisecond)
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
VALUES(?,1,?,?,?,?,?,?,?,?,?,?)
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`,
s.tenant(), cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now)
return err
@@ -252,9 +252,9 @@ func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxCo
func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) {
var cfg AdminFeedbackConfig
if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil {
if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
if errorsIsNoRows(err) {
return &AdminFeedbackConfig{ID: 1, Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 1000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。", UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil
return &AdminFeedbackConfig{Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 2000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil
}
return nil, err
}
@@ -263,8 +263,8 @@ func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig,
func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error {
now := time.Now().UTC().Round(time.Millisecond)
_, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
VALUES(?,1,?,?,?,?,?,?,?)
_, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`,
s.tenant(), defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now)
return err
+1 -50
View File
@@ -17,7 +17,6 @@ import (
"time"
"git.118im.com/im_team/im_workbench/internal/config"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/xtea"
)
@@ -106,14 +105,6 @@ func (s *Store) Close() error {
return s.db.Close()
}
type tenantDBConfig struct {
ID int64 `db:"id"`
Code string `db:"code"`
DBName string `db:"db_name"`
DBDSN string `db:"db_dsn"`
State int `db:"state"`
}
// ForTenant returns a tenant-scoped Store over the configured shared database.
func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) {
if s == nil {
@@ -139,46 +130,6 @@ func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string
return st, nil
}
func (s *Store) tenantDBConfig(ctx context.Context, tenantID int64, tenantCode string) (*tenantDBConfig, error) {
var cfg tenantDBConfig
if tenantID > 0 {
err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state
FROM hotgo.im_tenant WHERE id=? LIMIT 1`, tenantID)
if err == nil {
return &cfg, nil
}
if !errors.Is(err, sql.ErrNoRows) || strings.TrimSpace(tenantCode) == "" {
return nil, err
}
}
err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state
FROM hotgo.im_tenant WHERE code=? LIMIT 1`, strings.TrimSpace(tenantCode))
if err != nil {
return nil, err
}
return &cfg, nil
}
func tenantDSN(baseDSN, dbName, dbDSN string) (string, error) {
dbDSN = strings.TrimSpace(dbDSN)
if strings.HasPrefix(dbDSN, "mysql:") {
dbDSN = strings.TrimPrefix(dbDSN, "mysql:")
}
if dbDSN != "" {
return dbDSN, nil
}
dbName = strings.TrimSpace(dbName)
if dbName == "" {
return baseDSN, nil
}
cfg, err := mysqlDriver.ParseDSN(baseDSN)
if err != nil {
return "", err
}
cfg.DBName = dbName
return cfg.FormatDSN(), nil
}
func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) {
var modules []WorkbenchModule
err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission
@@ -1087,7 +1038,7 @@ func (s *Store) MonthlyReportStats(ctx context.Context, month, departmentID stri
func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) {
var cfg MailboxConfig
if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? AND id=1`, s.tenant()); err != nil {
if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &MailboxConfig{Enabled: true, AllowAnonymous: true, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", Version: 1}, nil
}
-30
View File
@@ -1,30 +0,0 @@
package store
import (
"strings"
"testing"
)
func TestTenantDSNUsesConfiguredDSN(t *testing.T) {
got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?parseTime=true", "", "mysql:user:secret@tcp(db:3306)/meeting_im?parseTime=true")
if err != nil {
t.Fatalf("tenantDSN returned error: %v", err)
}
want := "user:secret@tcp(db:3306)/meeting_im?parseTime=true"
if got != want {
t.Fatalf("tenantDSN = %q, want %q", got, want)
}
}
func TestTenantDSNDerivesDBName(t *testing.T) {
got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local", "meeting_im", "")
if err != nil {
t.Fatalf("tenantDSN returned error: %v", err)
}
if got == "" || got == "root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local" {
t.Fatalf("tenantDSN did not derive a tenant database DSN: %q", got)
}
if parsed := "meeting_im"; !strings.Contains(got, "/"+parsed+"?") {
t.Fatalf("tenantDSN = %q, want database %q", got, parsed)
}
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"time"
)
// TenantInitializationResult reports how many missing defaults were created.
// Existing tenant customizations are deliberately left untouched.
type TenantInitializationResult struct {
WorkbenchApps int64
AttendanceRules int64
AttendanceShifts int64
MailboxConfigs int64
FeedbackConfigs int64
}
// InitializeTenantDefaults creates the minimum workbench data required by a tenant.
// Tenant-scoped unique indexes make this operation safe to retry.
func (s *Store) InitializeTenantDefaults(ctx context.Context) (*TenantInitializationResult, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
now := time.Now().UTC().Round(time.Millisecond)
result := &TenantInitializationResult{}
apps := []struct {
code, name, route string
sort int
}{
{code: "attendance", name: "打卡", route: "/attendance", sort: 10},
{code: "monthly_reports", name: "月报", route: "/reports/monthly", sort: 20},
{code: "daily_reports", name: "日报", route: "/reports/daily", sort: 30},
{code: "organization", name: "部门框架", route: "/organization", sort: 40},
{code: "executive_mailbox", name: "举报信箱", route: "/executive-mailbox", sort: 50},
{code: "meetings", name: "会议通知", route: "/meetings", sort: 60},
{code: "meeting_notes", name: "会议笔记", route: "/meeting-notes", sort: 70},
}
for _, app := range apps {
res, execErr := tx.ExecContext(ctx, `INSERT INTO im_workbench_apps
(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`,
s.tenant(), app.code, app.name, "", app.route, app.sort, "", 1, "enabled", now, now)
if execErr != nil {
return nil, execErr
}
rows, _ := res.RowsAffected()
if rows == 1 {
result.WorkbenchApps++
}
}
res, err := tx.ExecContext(ctx, `INSERT INTO im_attendance_rules
(tenant_id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,effective_from,effective_to,state,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,CURDATE(),NULL,'active',?,?)
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`,
s.tenant(), "att_rule_default", "默认考勤规则", "1,2,3,4,5", 0, 0, 1, 1, now, now)
if err != nil {
return nil, err
}
result.AttendanceRules, _ = res.RowsAffected()
res, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_shifts
(tenant_id,shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,state,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`,
s.tenant(), "shift_default", "att_rule_default", "默认班次", "09:00:00", "18:00:00", "08:00:00", "19:00:00", "active", 10, now, now)
if err != nil {
return nil, err
}
result.AttendanceShifts, _ = res.RowsAffected()
res, err = tx.ExecContext(ctx, `INSERT INTO im_mailbox_config
(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
VALUES(?,1,1,?,5000,?,'总经理信箱','请如实填写诉求。',?,?,?)
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`,
s.tenant(), `["subject","content"]`, `{"max_files":5,"purpose":"executive_mailbox"}`, now, now, now)
if err != nil {
return nil, err
}
result.MailboxConfigs, _ = res.RowsAffected()
res, err = tx.ExecContext(ctx, `INSERT INTO im_feedback_config
(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
VALUES(?,?,2000,?,1,'',?,?)
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)`,
s.tenant(), `["bug","suggestion","complaint","other"]`, `{"max_files":5,"purpose":"feedback"}`, now, now)
if err != nil {
return nil, err
}
result.FeedbackConfigs, _ = res.RowsAffected()
if err = tx.Commit(); err != nil {
return nil, err
}
return result, nil
}