更新登录

This commit is contained in:
李志强 2026-04-02 17:53:50 +08:00
parent d62872cc6c
commit d5d6c9fb4c
7 changed files with 450 additions and 18 deletions

View File

@ -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()
}

View File

@ -48,6 +48,7 @@ func Init(_ string) {
new(SystemTenantDomain),
new(SystemModules),
new(PlatformLoginVerify),
new(TenantSiteSetting),
)
// 创建全局 Ormer

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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")
}

View File

@ -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")

View File

@ -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
}