diff --git a/controllers/site_settings.go b/controllers/site_settings.go new file mode 100644 index 0000000..c8d2401 --- /dev/null +++ b/controllers/site_settings.go @@ -0,0 +1,272 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// SiteSettingsController 租户站点设置(站点基本信息) +// 对应前端 normalSettings.vue 的: +// - GET /backend/normalInfos +// - POST /backend/saveNormalInfos +// - GET /platform/normalInfos +// - POST /platform/saveNormalInfos +type SiteSettingsController struct { + beego.Controller +} + +func (c *SiteSettingsController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *SiteSettingsController) claimsByPath() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + + path := strings.ToLower(c.Ctx.Request.URL.Path) + if strings.HasPrefix(path, "/platform/") { + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + } else if strings.HasPrefix(path, "/backend/") { + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + } + + return claims, nil +} + +func parseUint64Flexible(v interface{}) uint64 { + if v == nil { + return 0 + } + switch x := v.(type) { + case float64: + if x <= 0 { + return 0 + } + return uint64(x) + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0 + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || n == 0 { + return 0 + } + return n + default: + return 0 + } +} + +type normalInfosOutput struct { + Sitename string `json:"sitename"` + Companyintroduction string `json:"companyintroduction"` + Description string `json:"description"` + Copyright string `json:"copyright"` + Companyname string `json:"companyname"` + Icp string `json:"icp"` + Logo string `json:"logo"` + Logow string `json:"logow"` + Ico string `json:"ico"` +} + +// GetNormalInfos GET /backend/normalInfos 或 /platform/normalInfos +func (c *SiteSettingsController) GetNormalInfos() { + claims, err := c.claimsByPath() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + // 优先使用 token 中的租户 id;若为 0,则允许前端通过查询参数传入(兼容历史/平台端)。 + tid := uint64(claims.TenantId) + if tid == 0 { + tidStr := strings.TrimSpace(c.GetString("tid")) + if tidStr != "" { + if n, err := strconv.ParseUint(tidStr, 10, 64); err == nil { + tid = n + } + } + } + + out := normalInfosOutput{ + Sitename: "", + Companyintroduction: "", + Description: "", + Copyright: "", + Companyname: "", + Icp: "", + Logo: "", + Logow: "", + Ico: "", + } + + // tid 缺失时不报错,直接返回空对象给前端渲染(避免 UI 直接崩)。 + if tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() + return + } + + var rows []models.TenantSiteSetting + _, err = models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Limit(1). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取失败: "+err.Error()) + return + } + if len(rows) > 0 { + r := rows[0] + out.Sitename = r.Sitename + out.Companyintroduction = r.Companyintroduction + out.Logo = r.Logo + out.Logow = r.Logow + out.Ico = r.Ico + out.Description = r.Description + out.Copyright = r.Copyright + out.Companyname = r.Companyname + out.Icp = r.Icp + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +type normalInfosPayload struct { + // 前端会传 tid(但我们仍优先使用 token 的 tenant_id) + Tid interface{} `json:"tid"` + + Sitename string `json:"sitename"` + Companyintroduction string `json:"companyintroduction"` + Logo string `json:"logo"` + Logow string `json:"logow"` + Ico string `json:"ico"` + Description string `json:"description"` + Copyright string `json:"copyright"` + Companyname string `json:"companyname"` + Icp string `json:"icp"` +} + +// SaveNormalInfos POST /backend/saveNormalInfos 或 /platform/saveNormalInfos +func (c *SiteSettingsController) SaveNormalInfos() { + claims, err := c.claimsByPath() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p normalInfosPayload + if uerr := json.Unmarshal(raw, &p); uerr != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + tid := uint64(claims.TenantId) + if tid == 0 { + tid = parseUint64Flexible(p.Tid) + } + if tid == 0 { + c.jsonErr(400, 400, "tid不能为空") + return + } + + sitename := strings.TrimSpace(p.Sitename) + if sitename == "" { + c.jsonErr(400, 400, "站点名称不能为空") + return + } + + now := time.Now() + + up := map[string]interface{}{ + "tid": tid, + "sitename": sitename, + "companyintroduction": strings.TrimSpace(p.Companyintroduction), + "logo": strings.TrimSpace(p.Logo), + "logow": strings.TrimSpace(p.Logow), + "ico": strings.TrimSpace(p.Ico), + "description": strings.TrimSpace(p.Description), + "copyright": strings.TrimSpace(p.Copyright), + "companyname": strings.TrimSpace(p.Companyname), + "icp": strings.TrimSpace(p.Icp), + "update_time": now, + } + + cnt, err := models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + + if cnt == 0 { + row := &models.TenantSiteSetting{ + Tid: tid, + Sitename: sitename, + Companyintroduction: strings.TrimSpace(p.Companyintroduction), + Logo: strings.TrimSpace(p.Logo), + Logow: strings.TrimSpace(p.Logow), + Ico: strings.TrimSpace(p.Ico), + Description: strings.TrimSpace(p.Description), + Copyright: strings.TrimSpace(p.Copyright), + Companyname: strings.TrimSpace(p.Companyname), + Icp: strings.TrimSpace(p.Icp), + CreateTime: now, + UpdateTime: &now, + } + _, err = models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + } else { + _, err = models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(up) + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + diff --git a/models/init.go b/models/init.go index d4c51fb..7dc6c8f 100644 --- a/models/init.go +++ b/models/init.go @@ -48,6 +48,7 @@ func Init(_ string) { new(SystemTenantDomain), new(SystemModules), new(PlatformLoginVerify), + new(TenantSiteSetting), ) // 创建全局 Ormer diff --git a/models/system_tenant_domain.go b/models/system_tenant_domain.go index 99211cf..8d5541b 100644 --- a/models/system_tenant_domain.go +++ b/models/system_tenant_domain.go @@ -2,7 +2,7 @@ package models import "time" -// SystemTenantDomain 租户域名表 yz_system_tenant_domain +// SystemTenantDomain 租户域名表 yz_tenant_domain type SystemTenantDomain struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` Tid *uint64 `orm:"column(tid);null" json:"tid"` @@ -16,5 +16,5 @@ type SystemTenantDomain struct { } func (m *SystemTenantDomain) TableName() string { - return "yz_system_tenant_domain" + return "yz_tenant_domain" } diff --git a/models/tenant_site_setting.go b/models/tenant_site_setting.go new file mode 100644 index 0000000..6ddd0aa --- /dev/null +++ b/models/tenant_site_setting.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// TenantSiteSetting 租户站点设置表 yz_tenant_site_setting +// 主要用于“站点基本信息”配置(站点名称/Logo/企业介绍等) +type TenantSiteSetting struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + + Tid uint64 `orm:"column(tid);null" json:"tid"` + + Sitename string `orm:"column(sitename);size(255);null" json:"sitename"` + Logo string `orm:"column(logo);size(255);null" json:"logo"` + Logow string `orm:"column(logow);size(255);null" json:"logow"` + Ico string `orm:"column(ico);size(255);null" json:"ico"` + + Companyintroduction string `orm:"column(companyintroduction);type(longtext);null" json:"companyintroduction"` + Description string `orm:"column(description);size(255);null" json:"description"` + Copyright string `orm:"column(copyright);size(255);null" json:"copyright"` + Companyname string `orm:"column(companyname);size(255);null" json:"companyname"` + Icp string `orm:"column(icp);size(255);null" json:"icp"` + + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add;null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *TenantSiteSetting) TableName() string { + return "yz_tenant_site_setting" +} + diff --git a/routers/backend/backend.go b/routers/backend/backend.go index c7e9c84..98ff46a 100644 --- a/routers/backend/backend.go +++ b/routers/backend/backend.go @@ -47,4 +47,8 @@ func RegisterAuthRoutes() { beego.Router("/backend/modules/batchDelete", &controllers.PlatformModulesController{}, "post:BatchDelete") beego.Router("/backend/modules", &controllers.PlatformModulesController{}, "post:Add") beego.Router("/backend/modules/:id", &controllers.PlatformModulesController{}, "get:GetDetail;put:Edit;delete:Delete") + + // 租户站点设置(yz_tenant_site_setting) + beego.Router("/backend/normalInfos", &controllers.SiteSettingsController{}, "get:GetNormalInfos") + beego.Router("/backend/saveNormalInfos", &controllers.SiteSettingsController{}, "post:SaveNormalInfos") } diff --git a/routers/platform/platform.go b/routers/platform/platform.go index 3ab189a..6653f3b 100644 --- a/routers/platform/platform.go +++ b/routers/platform/platform.go @@ -95,6 +95,10 @@ func Register() { beego.Router("/platform/modules", &controllers.PlatformModulesController{}, "post:Add") beego.Router("/platform/modules/:id", &controllers.PlatformModulesController{}, "get:GetDetail;put:Edit;delete:Delete") + // 租户站点设置(yz_tenant_site_setting) + beego.Router("/platform/normalInfos", &controllers.SiteSettingsController{}, "get:GetNormalInfos") + beego.Router("/platform/saveNormalInfos", &controllers.SiteSettingsController{}, "post:SaveNormalInfos") + // 系统邮箱配置(yz_system_email) beego.Router("/platform/email/info", &controllers.PlatformEmailController{}, "get:GetInfo") beego.Router("/platform/email/editinfo", &controllers.PlatformEmailController{}, "post:EditInfo") diff --git a/services/login_verify_code.go b/services/login_verify_code.go index 8a24c9e..d365bde 100644 --- a/services/login_verify_code.go +++ b/services/login_verify_code.go @@ -1,9 +1,13 @@ package services import ( + "bytes" + "encoding/json" "errors" "fmt" + "io" "math/rand" + "net/http" "strings" "sync" "time" @@ -98,24 +102,48 @@ func SendBackendLoginCode(tenantName, account, channel string) error { if err := models.Orm.QueryTable(new(models.Tenant)).Filter("tenant_name", tenantName).One(&tenant); err != nil { return errors.New("租户不存在") } - var user models.TenantUser - if err := models.Orm.QueryTable(new(models.TenantUser)). - Filter("tid", tenant.ID). - Filter("account", account). - One(&user); err != nil { - return errors.New("用户不存在") - } - if user.Status == 0 { - return errors.New("账号已禁用") - } - if channel == "sms" && (user.Phone == nil || strings.TrimSpace(*user.Phone) == "") { - return errors.New("该账号未绑定手机号") - } - if channel == "email" && (user.Email == nil || strings.TrimSpace(*user.Email) == "") { - return errors.New("该账号未绑定邮箱") - } rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) + + // 规则:先校验租户,再校验“输入的手机号/邮箱”是否为该租户已绑定的记录 + switch channel { + case "sms": + phone := account + var user models.TenantUser + if err := models.Orm.QueryTable(new(models.TenantUser)). + Filter("tid", tenant.ID). + Filter("phone", phone). + One(&user); err != nil { + return errors.New("该手机号非当前企业绑定号码,请重试") + } + if user.Status == 0 { + return errors.New("账号已禁用") + } + if user.Phone == nil || strings.TrimSpace(*user.Phone) == "" { + return errors.New("该手机号非当前企业绑定号码,请重试") + } + + content := "短信验证码:" + code + if err := enqueueSMSTaskForLogin(tenant.ID, phone, content, code); err != nil { + return errors.New("短信发送失败,请重试") + } + case "email": + email := account + var user models.TenantUser + if err := models.Orm.QueryTable(new(models.TenantUser)). + Filter("tid", tenant.ID). + Filter("email", email). + One(&user); err != nil { + return errors.New("该账号未绑定邮箱") + } + if user.Status == 0 { + return errors.New("账号已禁用") + } + if user.Email == nil || strings.TrimSpace(*user.Email) == "" { + return errors.New("该账号未绑定邮箱") + } + } + loginCodeStore.Store(codeKey(tenantName+"#"+account, channel), loginCodeItem{ Code: code, Channel: channel, @@ -128,3 +156,95 @@ func VerifyBackendLoginCode(tenantName, account, channel, code string) error { return VerifyPlatformLoginCode(tenantName+"#"+account, channel, code) } +func getDefaultSystemSMSConfig() (backendURL string, apiKey string, err error) { + var row models.SystemSMS + err = models.Orm.QueryTable(new(models.SystemSMS)). + Filter("is_default", 1). + Filter("status", 1). + OrderBy("-weight", "-id"). + Limit(1). + One(&row) + if err != nil { + // fallback:自定义网关 + err2 := models.Orm.QueryTable(new(models.SystemSMS)). + Filter("config_code", "custom"). + OrderBy("-id"). + Limit(1). + One(&row) + if err2 != nil { + return "", "", err2 + } + } + backendURL = strings.TrimSpace(row.ApiURL) + apiKey = strings.TrimSpace(row.ApiKey) + return backendURL, apiKey, nil +} + +// enqueueSMSTaskForLogin 入队短信任务到网关,并写入 yz_system_sms_tasks +func enqueueSMSTaskForLogin(tid uint64, phone, content, code string) error { + backendURL, apiKey, err := getDefaultSystemSMSConfig() + if err != nil { + return err + } + if backendURL == "" || apiKey == "" { + return errors.New("短信网关未配置") + } + + enqueueURL := strings.TrimRight(backendURL, "/") + "/api/v1/business/outbound-tasks" + payload := map[string]interface{}{ + "phone": phone, + "content": content, + } + bs, _ := json.Marshal(payload) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("POST", enqueueURL, bytes.NewReader(bs)) + if err != nil { + return err + } + req.Header.Set("X-Api-Key", apiKey) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + bodyStr := strings.TrimSpace(string(bodyBytes)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gateway http status: %d, body: %s", resp.StatusCode, bodyStr) + } + + // 2xx:认为已成功提交 + now := time.Now() + tidCopy := tid + contentPtr := content + var reportPtr *string + if bodyStr != "" { + reportPtr = &bodyStr + } + + task := &models.SystemSMSTask{ + Tid: &tidCopy, + ApiKey: apiKey, + Phone: phone, + Content: &contentPtr, + Status: 3, + Code: code, + ReportRaw: reportPtr, + CreateTime: &now, + UpdateTime: &now, + } + + _, insertErr := models.Orm.Insert(task) + // 入队成功但写任务表失败:不影响用户侧体验 + if insertErr != nil { + return nil + } + return nil +} +