diff --git a/controllers/platform_admin_user.go b/controllers/platform_admin_user.go index 1ab0a79..5f22f20 100644 --- a/controllers/platform_admin_user.go +++ b/controllers/platform_admin_user.go @@ -11,7 +11,7 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// PlatformAdminUserController 平台管理员用户管理(yz_admin_user) +// PlatformAdminUserController 平台管理员用户管理(yz_system_admin_user) type PlatformAdminUserController struct { beego.Controller } @@ -25,7 +25,7 @@ type adminUserDTO struct { Qq *string `json:"qq"` Sex uint8 `json:"sex"` Avatar *string `json:"avatar"` - GroupID uint64 `json:"group_id"` + Rid uint64 `json:"rid"` LoginCount uint64 `json:"login_count"` LastLoginIP *string `json:"last_login_ip"` Status uint8 `json:"status"` @@ -48,7 +48,7 @@ func toAdminUserDTO(u models.AdminUser) adminUserDTO { Qq: u.Qq, Sex: u.Sex, Avatar: u.Avatar, - GroupID: u.RoleID, + Rid: u.RoleID, LoginCount: u.LoginCount, LastLoginIP: u.LastLoginIP, Status: u.Status, @@ -111,11 +111,11 @@ type adminAddUserPayload struct { Qq *string `json:"qq"` Sex *uint8 `json:"sex"` Avatar *string `json:"avatar"` - GroupID *uint64 `json:"group_id"` + Rid *uint64 `json:"rid"` Status *uint8 `json:"status"` } -// AddUser 添加平台管理员用户(仅写 yz_admin_user,不处理 tid) +// AddUser 添加平台管理员用户(仅写 yz_system_admin_user,不处理 tid) // POST /platform/addUser func (c *PlatformAdminUserController) AddUser() { var p adminAddUserPayload @@ -146,12 +146,12 @@ func (c *PlatformAdminUserController) AddUser() { if p.Sex != nil { sex = *p.Sex } - groupID := uint64(1) - if p.GroupID != nil && *p.GroupID != 0 { - groupID = *p.GroupID + roleID := uint64(1) + if p.Rid != nil && *p.Rid != 0 { + roleID = *p.Rid } - id, err := models.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, sex, groupID, status) + id, err := models.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, sex, roleID, status) if err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败"} _ = c.ServeJSON() @@ -175,7 +175,7 @@ type editUserPayload struct { Qq *string `json:"qq"` Sex *uint8 `json:"sex"` Avatar *string `json:"avatar"` - GroupID *uint64 `json:"group_id"` + Rid *uint64 `json:"rid"` Status *uint8 `json:"status"` } @@ -222,8 +222,8 @@ func (c *PlatformAdminUserController) EditUser() { if p.Avatar != nil { fields["avatar"] = *p.Avatar } - if p.GroupID != nil && *p.GroupID != 0 { - fields["role_id"] = *p.GroupID + if p.Rid != nil && *p.Rid != 0 { + fields["role_id"] = *p.Rid } if p.Status != nil { fields["status"] = *p.Status @@ -300,4 +300,3 @@ func (c *PlatformAdminUserController) ChangePassword() { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"} _ = c.ServeJSON() } - diff --git a/controllers/platform_auth.go b/controllers/platform_auth.go index cbc3fdf..46af65a 100644 --- a/controllers/platform_auth.go +++ b/controllers/platform_auth.go @@ -3,7 +3,9 @@ package controllers import ( "encoding/json" "io" + "strings" + "server/pkg/jwtutil" "server/services" beego "github.com/beego/beego/v2/server/web" @@ -69,17 +71,64 @@ func (c *PlatformAuthController) Login() { "data": map[string]interface{}{ "token": token, "user": map[string]interface{}{ - "id": loginUser.ID, - "account": loginUser.Account, - "name": loginUser.Name, - "rid": loginUser.Rid, - "avatar": loginUser.Avatar, + "id": loginUser.ID, + "account": loginUser.Account, + "name": loginUser.Name, + "rid": loginUser.Rid, + "avatar": loginUser.Avatar, + "role_name": loginUser.RoleName, }, }, } _ = c.ServeJSON() } +// GetCurrentUser 当前登录平台用户信息(含角色名称),需 Bearer Token +func (c *PlatformAuthController) GetCurrentUser() { + authHeader := c.Ctx.Request.Header.Get("Authorization") + if authHeader == "" { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录"} + _ = c.ServeJSON() + return + } + authParts := strings.SplitN(authHeader, " ", 2) + if len(authParts) != 2 || authParts[0] != "Bearer" { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "认证信息格式错误"} + _ = c.ServeJSON() + return + } + claims, err := jwtutil.ParseToken(authParts[1]) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "无效的token"} + _ = c.ServeJSON() + return + } + if claims.UserType != "platform" { + c.Data["json"] = map[string]interface{}{"code": 403, "msg": "无权访问"} + _ = c.ServeJSON() + return + } + loginUser, err := services.PlatformGetCurrentUser(uint64(claims.UserID)) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": err.Error()} + _ = c.ServeJSON() + return + } + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "id": loginUser.ID, + "account": loginUser.Account, + "name": loginUser.Name, + "rid": loginUser.Rid, + "avatar": loginUser.Avatar, + "role_name": loginUser.RoleName, + }, + } + _ = c.ServeJSON() +} + // SendLoginCode 发送登录验证码(占位实现) func (c *PlatformAuthController) SendLoginCode() { c.Data["json"] = map[string]interface{}{ diff --git a/controllers/platform_email.go b/controllers/platform_email.go new file mode 100644 index 0000000..b678870 --- /dev/null +++ b/controllers/platform_email.go @@ -0,0 +1,265 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformEmailController 系统邮箱配置(yz_system_email) +type PlatformEmailController struct { + beego.Controller +} + +func (c *PlatformEmailController) platformClaims() (*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") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformEmailController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func emailRowToMap(m models.SystemEmail) map[string]interface{} { + out := map[string]interface{}{ + "id": m.ID, + "from_address": m.FromAddress, + "host": m.Host, + "port": m.Port, + "password": m.Password, + "encryption": m.Encryption, + "timeout": m.Timeout, + "status": m.Status, + "create_time": m.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": m.UpdateTime.Format("2006-01-02 15:04:05"), + } + if m.FromName != nil { + out["from_name"] = *m.FromName + } else { + out["from_name"] = "" + } + if m.Remark != nil { + out["remark"] = *m.Remark + } else { + out["remark"] = "" + } + return out +} + +// GetInfo GET /platform/email/info +func (c *PlatformEmailController) GetInfo() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + rows, err := services.ListSystemEmails() + if err != nil { + c.jsonErr(500, 500, "获取邮箱配置失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, emailRowToMap(rows[i])) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +type emailFormPayload struct { + FromAddress string `json:"fromAddress"` + FromName string `json:"fromName"` + Host string `json:"host"` + Port interface{} `json:"port"` + Password string `json:"password"` + Encryption string `json:"encryption"` + Timeout interface{} `json:"timeout"` +} +type testEmailPayload struct { + emailFormPayload + TestEmail string `json:"testEmail"` +} + +func parseUintFlexible(v interface{}) uint { + if v == nil { + return 0 + } + switch x := v.(type) { + case float64: + if x < 0 { + return 0 + } + return uint(x) + case string: + n, err := parseUintString(x) + if err != nil { + return 0 + } + return n + default: + return 0 + } +} + +func parseUintString(s string) (uint, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty") + } + n, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, err + } + return uint(n), nil +} + +func normalizeEncryption(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "ssl", "tls", "none": + return s + default: + return "ssl" + } +} + +// EditInfo POST /platform/email/editinfo +func (c *PlatformEmailController) EditInfo() { + if _, err := c.platformClaims(); 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 emailFormPayload + if uerr := json.Unmarshal(raw, &p); uerr != nil { + c.jsonErr(400, 400, "参数错误") + return + } + from := strings.TrimSpace(p.FromAddress) + host := strings.TrimSpace(p.Host) + if from == "" || host == "" { + c.jsonErr(400, 400, "发件人邮箱与 SMTP 主机不能为空") + return + } + port := parseUintFlexible(p.Port) + if port == 0 { + port = 465 + } + timeout := parseUintFlexible(p.Timeout) + if timeout == 0 { + timeout = 30 + } + enc := normalizeEncryption(p.Encryption) + cnt, cerr := models.Orm.QueryTable(new(models.SystemEmail)).Count() + if cerr != nil { + c.jsonErr(500, 500, "读取邮箱配置失败: "+cerr.Error()) + return + } + if strings.TrimSpace(p.Password) == "" && cnt == 0 { + c.jsonErr(400, 400, "授权码/密码不能为空") + return + } + var fn *string + if strings.TrimSpace(p.FromName) != "" { + s := strings.TrimSpace(p.FromName) + fn = &s + } + err = services.UpsertFirstSystemEmail(from, fn, host, port, strings.TrimSpace(p.Password), enc, timeout, 1, nil) + if err != nil { + c.jsonErr(500, 500, "保存邮箱配置失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +// SendTestEmail POST /platform/email/sendtestemail +func (c *PlatformEmailController) SendTestEmail() { + if _, err := c.platformClaims(); 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 testEmailPayload + if uerr := json.Unmarshal(raw, &p); uerr != nil { + c.jsonErr(400, 400, "参数错误") + return + } + to := strings.TrimSpace(p.TestEmail) + if to == "" { + c.jsonErr(400, 400, "测试收件邮箱不能为空") + return + } + from := strings.TrimSpace(p.FromAddress) + host := strings.TrimSpace(p.Host) + if from == "" || host == "" { + c.jsonErr(400, 400, "发件人邮箱与 SMTP 主机不能为空") + return + } + port := parseUintFlexible(p.Port) + if port == 0 { + port = 465 + } + timeout := parseUintFlexible(p.Timeout) + if timeout == 0 { + timeout = 30 + } + enc := normalizeEncryption(p.Encryption) + pass := strings.TrimSpace(p.Password) + if pass == "" { + rows, lerr := services.ListSystemEmails() + if lerr == nil && len(rows) > 0 { + pass = rows[0].Password + } + } + if pass == "" { + c.jsonErr(400, 400, "授权码/密码不能为空(请填写或先保存配置)") + return + } + cfg := services.SMTPConfig{ + FromAddress: from, + FromName: strings.TrimSpace(p.FromName), + Host: host, + Port: port, + Password: pass, + Encryption: enc, + Timeout: timeout, + } + if err := services.SendTestEmailSMTP(cfg, to); err != nil { + c.jsonErr(500, 500, "发送失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "发送成功"} + _ = c.ServeJSON() +} diff --git a/controllers/platform_file.go b/controllers/platform_file.go new file mode 100644 index 0000000..36f0ed9 --- /dev/null +++ b/controllers/platform_file.go @@ -0,0 +1,924 @@ +package controllers + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformFileController 平台端文件管理(yz_system_files / yz_system_files_category) +type PlatformFileController struct { + beego.Controller +} + +const fileUploadMaxBytes = 50 * 1024 * 1024 + +var fileTypeByCategory = map[string]uint8{ + "image": 1, + "document": 2, + "video": 3, + "audio": 4, +} + +var allowedExtByCategory = map[string][]string{ + "image": {"jpg", "jpeg", "png", "gif", "bmp", "webp"}, + "document": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt"}, + "video": {"mp4", "webm", "mov"}, + "audio": {"mp3", "wav", "ogg"}, +} + +func (c *PlatformFileController) platformClaims() (*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") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformFileController) effectiveTid(claims *jwtutil.Claims) uint64 { + _ = c.ParseForm(1 << 20) + if tid, err := c.GetUint64("tid"); err == nil && tid > 0 { + return tid + } + if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-Id")); h != "" { + if v, e := strconv.ParseUint(h, 10, 64); e == nil { + return v + } + } + if claims != nil && claims.TenantId > 0 { + return uint64(claims.TenantId) + } + return 0 +} + +func (c *PlatformFileController) 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 *PlatformFileController) jsonOK(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func detectFileType(ext string) uint8 { + ext = strings.ToLower(strings.TrimPrefix(ext, ".")) + for cat, exts := range allowedExtByCategory { + for _, e := range exts { + if e == ext { + return fileTypeByCategory[cat] + } + } + } + return 2 +} + +func fileExt(name string) string { + name = strings.TrimSpace(name) + if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 { + return strings.ToLower(name[i+1:]) + } + return "" +} + +func fileToMap(f *models.SystemFile) map[string]interface{} { + ct := f.CreateTime.Format("2006-01-02 15:04:05") + m := map[string]interface{}{ + "id": f.ID, + "tid": f.Tid, + "name": f.Name, + "type": f.Type, + "cate": f.Cate, + "size": f.Size, + "src": f.Src, + "uploader": f.Uploader, + "md5": f.Md5, + "create_time": ct, + "createTime": ct, + "groupId": f.Cate, + "url": f.Src, + } + if f.Uid != nil { + m["uid"] = *f.Uid + } + if f.Tuid != nil { + m["tuid"] = *f.Tuid + } + return m +} + +func removePhysicalBySrc(webSrc string) { + webSrc = strings.TrimSpace(webSrc) + if webSrc == "" { + return + } + webSrc = strings.TrimPrefix(webSrc, "/") + _ = os.Remove(webSrc) +} + +// GetAllFiles GET /platform/allfiles +func (c *PlatformFileController) GetAllFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + cate, _ := c.GetUint64("cate") + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if cate > 0 { + qs = qs.Filter("cate", cate) + } + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取文件列表失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, fileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetUserCate GET /platform/usercate +func (c *PlatformFileController) GetUserCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + + var cates []models.SystemFilesCategory + _, err = models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&cates) + if err != nil { + c.jsonErr(500, 500, "获取用户分类失败: "+err.Error()) + return + } + out := make([]map[string]interface{}, 0, len(cates)) + for i := range cates { + cnt, _ := models.Orm.QueryTable(new(models.SystemFile)). + Filter("tid", tid). + Filter("cate", cates[i].ID). + Filter("delete_time__isnull", true). + Count() + out = append(out, map[string]interface{}{ + "id": cates[i].ID, + "name": cates[i].Name, + "total": cnt, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +type createCateBody struct { + Name string `json:"name"` + Tuid *uint64 `json:"tuid"` +} + +// CreateFileCate POST /platform/createfilecate +func (c *PlatformFileController) CreateFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body createCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + uid := uint64(claims.UserID) + row := &models.SystemFilesCategory{ + Tid: tid, + Name: name, + Uid: &uid, + Tuid: body.Tuid, + } + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "新建文件分组失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "新建文件分组成功", + "data": map[string]interface{}{"id": uint64(id)}, + } + _ = c.ServeJSON() +} + +type renameCateBody struct { + Name string `json:"name"` +} + +// RenameFileCate POST /platform/renamefilecate/:id +func (c *PlatformFileController) RenameFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body renameCateBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.jsonErr(400, 400, "分组名称不能为空") + return + } + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"name": name}) + if err != nil { + c.jsonErr(500, 500, "重命名文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "重命名文件分组成功"} + _ = c.ServeJSON() +} + +// DeleteFileCate DELETE /platform/deletefilecate/:id +func (c *PlatformFileController) DeleteFileCate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的分组ID") + return + } + cnt, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("cate", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if cnt > 0 { + c.jsonErr(400, 400, fmt.Sprintf("该分组下还有 %d 个文件,请先删除分组内文件!", cnt)) + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除文件分组失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "分组不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除文件分组成功"} + _ = c.ServeJSON() +} + +// GetCateFiles GET /platform/catefiles/:id +func (c *PlatformFileController) GetCateFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + cateID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.jsonErr(400, 400, "无效的分类ID") + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 24) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 24 + } + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.SystemFile)). + Filter("tid", tid). + Filter("cate", cateID). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + var rows []models.SystemFile + _, err = qs.OrderBy("-create_time").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取分类文件失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, fileToMap(&rows[i])) + } + c.jsonOK(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + "categoryId": cateID, + }) +} + +// GetFileByID GET /platform/file/:id +func (c *PlatformFileController) GetFileByID() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + c.jsonOK(fileToMap(&f)) +} + +// UploadFile POST /platform/uploadfile +func (c *PlatformFileController) UploadFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + if err := c.Ctx.Request.ParseMultipartForm(fileUploadMaxBytes); err != nil { + c.jsonErr(400, 400, "解析上传失败: "+err.Error()) + return + } + fh, header, err := c.GetFile("file") + if err != nil || fh == nil { + c.jsonErr(400, 400, "请选择要上传的文件") + return + } + defer fh.Close() + + if header != nil && header.Size > fileUploadMaxBytes { + c.jsonErr(400, 400, "文件大小不能超过50MB") + return + } + + ext := fileExt(header.Filename) + if ext == "" { + c.jsonErr(400, 400, "无法识别文件扩展名") + return + } + + tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("up_%d_%s", time.Now().UnixNano(), header.Filename)) + tmp, err := os.Create(tmpPath) + if err != nil { + c.jsonErr(500, 500, "创建临时文件失败") + return + } + n, copyErr := io.Copy(tmp, fh) + _ = tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + c.jsonErr(500, 500, "读取文件失败") + return + } + if n > fileUploadMaxBytes { + _ = os.Remove(tmpPath) + c.jsonErr(400, 400, "文件大小不能超过50MB") + return + } + sum, err := md5HashFile(tmpPath) + if err != nil { + _ = os.Remove(tmpPath) + c.jsonErr(500, 500, "计算文件摘要失败") + return + } + + var exist models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("md5", sum). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&exist) + if err == nil { + _ = os.Remove(tmpPath) + c.Data["json"] = map[string]interface{}{ + "code": 201, + "msg": "文件已存在", + "data": map[string]interface{}{ + "url": exist.Src, + "id": exist.ID, + "name": exist.Name, + }, + } + _ = c.ServeJSON() + return + } + + datePath := time.Now().Format("2006/01/02") + saveName := fmt.Sprintf("%s/%d.%s", datePath, time.Now().UnixNano(), ext) + destDir := filepath.Join("uploads", filepath.FromSlash(datePath)) + if err := os.MkdirAll(destDir, 0755); err != nil { + _ = os.Remove(tmpPath) + c.jsonErr(500, 500, "创建目录失败: "+err.Error()) + return + } + destPath := filepath.Join("uploads", filepath.FromSlash(saveName)) + if err := os.Rename(tmpPath, destPath); err != nil { + _ = os.Remove(tmpPath) + c.jsonErr(500, 500, "保存文件失败: "+err.Error()) + return + } + + webURL := "/" + strings.ReplaceAll(filepath.ToSlash(destPath), "\\", "/") + + cateStr := c.GetString("cate") + var cate uint64 + if cateStr != "" { + cate, _ = strconv.ParseUint(cateStr, 10, 64) + } + + adminID := uint64(claims.UserID) + var tuidPtr *uint64 + if ts := strings.TrimSpace(c.GetString("tuid")); ts != "" { + if v, e := strconv.ParseUint(ts, 10, 64); e == nil { + tuidPtr = &v + } + } + + row := &models.SystemFile{ + Tid: tid, + Uid: &adminID, + Tuid: tuidPtr, + Name: header.Filename, + Type: detectFileType(ext), + Cate: cate, + Size: uint64(n), + Src: webURL, + Uploader: adminID, + Md5: sum, + } + id, err := models.Orm.Insert(row) + if err != nil { + removePhysicalBySrc(webURL) + c.jsonErr(500, 500, "上传失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "上传成功", + "data": map[string]interface{}{ + "url": webURL, + "id": uint64(id), + "name": header.Filename, + }, + } + _ = c.ServeJSON() +} + +func md5HashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +type updateFileBody struct { + Name *string `json:"name"` + Cate *uint64 `json:"cate"` +} + +// UpdateFile POST /platform/updatefile/:id +func (c *PlatformFileController) UpdateFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body updateFileBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + up := map[string]interface{}{} + if body.Name != nil { + up["name"] = strings.TrimSpace(*body.Name) + } + if body.Cate != nil { + up["cate"] = *body.Cate + } + if len(up) == 0 { + c.jsonErr(400, 400, "无更新数据") + return + } + now := time.Now() + up["update_time"] = now + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(up) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// DeleteFile DELETE /platform/deletefile/:id +func (c *PlatformFileController) DeleteFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// DeleteFilePermanently DELETE /platform/deletefilepermanently/:id +func (c *PlatformFileController) DeleteFilePermanently() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + var f models.SystemFile + err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + One(&f) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + removePhysicalBySrc(f.Src) + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "永久删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "永久删除成功"} + _ = c.ServeJSON() +} + +// MoveFile GET /platform/movefile/:id +func (c *PlatformFileController) MoveFile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效的文件ID") + return + } + cate, _ := c.GetUint64("cate") + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "移动成功"} + _ = c.ServeJSON() +} + +type idsBody struct { + IDs []uint64 `json:"ids"` + Cate *uint64 `json:"cate"` +} + +// BatchDeleteFiles POST /platform/batchdeletefiles +func (c *PlatformFileController) BatchDeleteFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的文件") + return + } + now := time.Now() + for _, id := range body.IDs { + var f models.SystemFile + e := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id", id). + Filter("tid", tid). + One(&f) + if e == nil && f.Src != "" { + removePhysicalBySrc(f.Src) + } + } + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} + _ = c.ServeJSON() +} + +// BatchDeleteFilesPermanently POST /platform/batchDeleteFilesPermanently +func (c *PlatformFileController) BatchDeleteFilesPermanently() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要彻底删除的文件") + return + } + var rows []models.SystemFile + _, err = models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + for i := range rows { + removePhysicalBySrc(rows[i].Src) + } + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Delete() + if err != nil { + c.jsonErr(500, 500, "批量彻底删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量彻底删除成功"} + _ = c.ServeJSON() +} + +// UploadAvatar POST /platform/uploadavatar(占位) +func (c *PlatformFileController) UploadAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"} + _ = c.ServeJSON() +} + +// UpdateAvatar POST /platform/uploadavatar/:id(占位) +func (c *PlatformFileController) UpdateAvatar() { + c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"} + _ = c.ServeJSON() +} + +// BatchMoveFiles POST /platform/batchMoveFiles +func (c *PlatformFileController) BatchMoveFiles() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := c.effectiveTid(claims) + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body idsBody + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(body.IDs) == 0 { + c.jsonErr(400, 400, "请选择要移动的文件") + return + } + if body.Cate == nil { + c.jsonErr(400, 400, "缺少目标分类") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemFile)). + Filter("id__in", body.IDs). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"cate": *body.Cate, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量移动失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "文件不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量移动成功"} + _ = c.ServeJSON() +} diff --git a/controllers/platform_role.go b/controllers/platform_role.go index 8a40e23..59472cd 100644 --- a/controllers/platform_role.go +++ b/controllers/platform_role.go @@ -11,7 +11,7 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// PlatformRoleController 平台角色管理(yz_admin_role) +// PlatformRoleController 平台角色管理(yz_system_admin_role) type PlatformRoleController struct { beego.Controller } @@ -199,4 +199,3 @@ func (c *PlatformRoleController) DeleteRole() { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} _ = c.ServeJSON() } - diff --git a/controllers/platform_sms.go b/controllers/platform_sms.go new file mode 100644 index 0000000..b667403 --- /dev/null +++ b/controllers/platform_sms.go @@ -0,0 +1,519 @@ +package controllers + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformSMSController 短信配置(yz_system_sms),兼容旧前端 /platform/sms/* 接口 +type PlatformSMSController struct { + beego.Controller +} + +func (c *PlatformSMSController) platformClaims() (*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") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformSMSController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetSmsInfo GET /platform/sms/info +// 返回 data[0],字段兼容 backend_url/api_key 与 backendUrl/apiKey(沿用旧前端) +func (c *PlatformSMSController) GetSmsInfo() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + var row models.SystemSMS + // 优先默认通道,其次 custom + err := models.Orm.QueryTable(new(models.SystemSMS)). + Filter("is_default", 1). + Filter("status", 1). + OrderBy("-weight", "-id"). + Limit(1). + One(&row) + if err != nil { + _ = models.Orm.QueryTable(new(models.SystemSMS)). + Filter("config_code", "custom"). + OrderBy("-id"). + Limit(1). + One(&row) + } + + backendURL := strings.TrimSpace(row.ApiURL) + apiKey := strings.TrimSpace(row.ApiKey) + + data := []map[string]interface{}{{ + "backend_url": backendURL, + "api_key": apiKey, + "backendUrl": backendURL, + "apiKey": apiKey, + }} + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "获取成功", "data": data} + _ = c.ServeJSON() +} + +type smsEditPayload struct { + BackendUrl string `json:"backendUrl"` + BackendURL string `json:"backend_url"` + ApiKey string `json:"apiKey"` + APIKey string `json:"api_key"` +} + +// EditSmsInfo POST /platform/sms/editinfo +// 将旧前端的 backendUrl/apiKey 落到 yz_system_sms 的 api_url/api_key(写入 config_code=custom) +func (c *PlatformSMSController) EditSmsInfo() { + if _, err := c.platformClaims(); 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 smsEditPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + backendURL := strings.TrimSpace(p.BackendUrl) + if backendURL == "" { + backendURL = strings.TrimSpace(p.BackendURL) + } + apiKey := strings.TrimSpace(p.ApiKey) + if apiKey == "" { + apiKey = strings.TrimSpace(p.APIKey) + } + if backendURL == "" { + c.jsonErr(400, 400, "请输入短信网关地址") + return + } + if apiKey == "" { + c.jsonErr(400, 400, "请输入API KEY") + return + } + + // 确保只有一个默认:先清空默认,再 upsert custom 为默认 + _, _ = models.Orm.QueryTable(new(models.SystemSMS)).Update(map[string]interface{}{"is_default": 0}) + + var existed models.SystemSMS + e := models.Orm.QueryTable(new(models.SystemSMS)).Filter("config_code", "custom").Limit(1).One(&existed) + if e == nil && existed.ID > 0 { + _, err = models.Orm.QueryTable(new(models.SystemSMS)).Filter("id", existed.ID).Update(map[string]interface{}{ + "config_name": "自定义网关", + "channel_type": 2, + "api_url": backendURL, + "api_key": apiKey, + "weight": 10, + "is_default": 1, + "status": 1, + }) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + } else { + row := &models.SystemSMS{ + ConfigCode: "custom", + ConfigName: "自定义网关", + ChannelType: 2, + ApiURL: backendURL, + ApiKey: apiKey, + ApiSecret: "", + SignName: "", + TemplateID: "", + TestPhone: "", + Weight: 10, + IsDefault: 1, + Status: 1, + Remark: "", + } + if _, err := models.Orm.Insert(row); err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + } + + updated := []map[string]interface{}{{ + "backend_url": backendURL, + "api_key": apiKey, + }} + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功", "data": updated} + _ = c.ServeJSON() +} + +type smsTestPayload struct { + BackendUrl string `json:"backendUrl"` + BackendURL string `json:"backend_url"` + ApiKey string `json:"apiKey"` + APIKey string `json:"api_key"` + Tid *uint64 `json:"tid"` + Phone string `json:"phone"` + Content string `json:"content"` +} + +// SendTestSms POST /platform/sms/sendtest +// 调用短信网关入队接口:{backendUrl}/api/v1/business/outbound-tasks,header: X-Api-Key +func (c *PlatformSMSController) SendTestSms() { + if _, err := c.platformClaims(); 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 smsTestPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + phone := strings.TrimSpace(p.Phone) + if phone == "" { + c.jsonErr(400, 400, "缺少测试手机号") + return + } + if !strings.HasPrefix(phone, "+") { + c.jsonErr(400, 400, "请使用国际格式手机号(以 + 开头,后为数字)") + return + } + for _, ch := range phone[1:] { + if ch < '0' || ch > '9' { + c.jsonErr(400, 400, "请使用国际格式手机号(以 + 开头,后为数字)") + return + } + } + + backendURL := strings.TrimSpace(p.BackendUrl) + if backendURL == "" { + backendURL = strings.TrimSpace(p.BackendURL) + } + apiKey := strings.TrimSpace(p.ApiKey) + if apiKey == "" { + apiKey = strings.TrimSpace(p.APIKey) + } + + // 兜底:body 未带时从默认配置取 + if backendURL == "" || apiKey == "" { + var row models.SystemSMS + _ = models.Orm.QueryTable(new(models.SystemSMS)). + Filter("is_default", 1). + Filter("status", 1). + OrderBy("-weight", "-id"). + Limit(1). + One(&row) + if backendURL == "" { + backendURL = strings.TrimSpace(row.ApiURL) + } + if apiKey == "" { + apiKey = strings.TrimSpace(row.ApiKey) + } + } + if backendURL == "" { + c.jsonErr(400, 400, "请先配置短信网关地址 backendUrl") + return + } + if apiKey == "" { + c.jsonErr(400, 400, "请先配置短信网关 API KEY") + return + } + + content := strings.TrimSpace(p.Content) + code := randomDigits6() + if content == "" { + content = "短信测试验证码:" + code + } + + 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 { + c.jsonErr(500, 500, "创建请求失败: "+err.Error()) + return + } + 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 { + c.jsonErr(500, 500, "短信网关入队失败: "+err.Error()) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + c.jsonErr(500, 500, "短信网关入队失败: "+msg) + return + } + + bodyStr := string(body) + report := strings.TrimSpace(bodyStr) + var reportPtr *string + if report != "" { + reportPtr = &bodyStr + } + + // 网关 HTTP 2xx:平台侧视为「已受理并成功提交」;与前端 tasklist 中 status=3「发送成功」对齐 + taskStatus := 3 + + // 若网关返回 JSON 且含通用状态字段,则优先映射(便于以后网关回传异步状态) + var gw map[string]interface{} + if json.Unmarshal(body, &gw) == nil { + if v, ok := gw["status"]; ok { + switch x := v.(type) { + case float64: + taskStatus = mapGatewayStatus(int(x)) + case string: + if n, e := strconv.Atoi(strings.TrimSpace(x)); e == nil { + taskStatus = mapGatewayStatus(n) + } + } + } + } + + // 写入本地任务表(用于前端列表/对账) + now := time.Now() + task := &models.SystemSMSTask{ + Tid: p.Tid, // 测试可为空 + ApiKey: apiKey, + Phone: phone, + Content: &content, + Status: taskStatus, + Code: code, + ReportRaw: reportPtr, + CreateTime: &now, + UpdateTime: &now, + } + taskID, terr := models.Orm.Insert(task) + if terr != nil { + // 入队已成功,任务写库失败也不影响短信发送,只返回提示 + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "短信测试任务入队成功(任务写库失败)", + "data": map[string]interface{}{ + "taskId": nil, + "code": code, + "gatewayResp": json.RawMessage(body), + }, + } + _ = c.ServeJSON() + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "短信测试任务入队成功", + "data": map[string]interface{}{ + "taskId": uint64(taskID), + "code": code, + "gatewayResp": json.RawMessage(body), + }, + } + _ = c.ServeJSON() +} + +// GetSmsTaskList GET /platform/sms/taskList +func (c *PlatformSMSController) GetSmsTaskList() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + statusStr := strings.TrimSpace(c.GetString("status")) + phoneKw := strings.TrimSpace(c.GetString("phone")) + tidStr := strings.TrimSpace(c.GetString("tid")) + + qs := models.Orm.QueryTable(new(models.SystemSMSTask)).Filter("delete_time__isnull", true) + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + qs = qs.Filter("status", st) + } + } + if phoneKw != "" { + qs = qs.Filter("phone__icontains", phoneKw) + } + if tidStr != "" { + if tid, err := strconv.ParseUint(tidStr, 10, 64); err == nil && tid > 0 { + qs = qs.Filter("tid", tid) + } + } + + var rows []models.SystemSMSTask + _, err := qs.OrderBy("-id").All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取短信任务列表失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + item := map[string]interface{}{ + "id": rows[i].ID, + "api_key": rows[i].ApiKey, + "phone": rows[i].Phone, + "content": "", + "status": rows[i].Status, + "code": rows[i].Code, + "report_raw": rows[i].ReportRaw, + "create_time": "", + "update_time": "", + } + if rows[i].Tid != nil { + item["tid"] = *rows[i].Tid + } + if rows[i].Content != nil { + item["content"] = *rows[i].Content + } + if rows[i].CreateTime != nil { + item["create_time"] = rows[i].CreateTime.Format("2006-01-02 15:04:05") + } + if rows[i].UpdateTime != nil { + item["update_time"] = rows[i].UpdateTime.Format("2006-01-02 15:04:05") + } + list = append(list, item) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "list": list} + _ = c.ServeJSON() +} + +// EditSmsTask POST /platform/sms/taskEdit/:id +func (c *PlatformSMSController) EditSmsTask() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p map[string]interface{} + _ = json.Unmarshal(raw, &p) + + up := map[string]interface{}{} + if v, ok := p["status"]; ok { + switch x := v.(type) { + case float64: + up["status"] = int(x) + case string: + if n, e := strconv.Atoi(strings.TrimSpace(x)); e == nil { + up["status"] = n + } + } + } + if v, ok := p["report_raw"]; ok { + if s, ok := v.(string); ok { + up["report_raw"] = s + } + } + if v, ok := p["content"]; ok { + if s, ok := v.(string); ok { + up["content"] = s + } + } + if len(up) == 0 { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() + return + } + now := time.Now() + up["update_time"] = now + n, err := models.Orm.QueryTable(new(models.SystemSMSTask)).Filter("id", id).Update(up) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "记录不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +func randomDigits6() string { + // 生成 6 位数字字符串 + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "123456" + } + n := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) + if n < 0 { + n = -n + } + code := n%900000 + 100000 + return strconv.Itoa(code) +} + +// mapGatewayStatus 将网关侧 status 粗略映射到前端列表:0待发送 1发送中 2失败 3成功 +func mapGatewayStatus(st int) int { + switch st { + case 0: + return 0 + case 1, 4, 5: + return 1 + case 2, 6: + return 2 + case 3: + return 3 + default: + // 网关枚举未约定时:HTTP 已 2xx,按「已成功提交」显示为发送成功 + return 3 + } +} diff --git a/controllers/platform_tenant_user.go b/controllers/platform_tenant_user.go index 25f7c9b..6700807 100644 --- a/controllers/platform_tenant_user.go +++ b/controllers/platform_tenant_user.go @@ -11,6 +11,7 @@ import ( "server/models" + "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) @@ -32,18 +33,46 @@ type tenantUserPayload struct { Remark *string `json:"remark"` } -// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤) -// GET /platform/tenantUser/list?tid=1&uid=2 +// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤;keyword 对姓名/手机/邮箱/账号模糊 OR 匹配) +// GET /platform/tenantUser/list?tid=1&uid=2&keyword=张 func (c *PlatformTenantUserController) GetTenantUserList() { tid, _ := c.GetUint64("tid") uid, _ := c.GetUint64("uid") + keyword := strings.TrimSpace(c.GetString("keyword")) qs := models.Orm.QueryTable(new(models.TenantUser)) + + var cond *orm.Condition + needCond := false if tid > 0 { - qs = qs.Filter("tid", tid) + if cond == nil { + cond = orm.NewCondition() + } + cond = cond.And("tid", tid) + needCond = true } if uid > 0 { - qs = qs.Filter("uid", uid) + if cond == nil { + cond = orm.NewCondition() + } + cond = cond.And("uid", uid) + needCond = true + } + if keyword != "" { + kwCond := orm.NewCondition() + kwCond = kwCond.Or("name__icontains", keyword). + Or("phone__icontains", keyword). + Or("email__icontains", keyword). + Or("account__icontains", keyword) + if cond == nil { + cond = kwCond + } else { + cond = cond.AndCond(kwCond) + } + needCond = true + } + if needCond { + qs = qs.SetCond(cond) } var rows []models.TenantUser @@ -115,7 +144,7 @@ func (c *PlatformTenantUserController) GetTenantUserDetail() { _ = c.ServeJSON() } -// CreateTenantUser 创建绑定 +// CreateTenantUser 创建租户用户绑定(写入表 yz_system_tenant_user;uid 为空时由 generateTenantUID 生成) // POST /platform/tenantUser/create func (c *PlatformTenantUserController) CreateTenantUser() { p, ok := c.parsePayload() @@ -286,4 +315,3 @@ func generateTenantUID(tid uint64) (uint64, error) { } return 0, errors.New("uid collision") } - diff --git a/controllers/platform_user.go b/controllers/platform_user.go index df2ea4e..9d05d77 100644 --- a/controllers/platform_user.go +++ b/controllers/platform_user.go @@ -12,7 +12,7 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// PlatformUserController 平台端用户相关(简化:当前用户信息落在 yz_tenant_user) +// PlatformUserController 平台端用户相关(简化:当前用户信息落在 yz_system_tenant_user) type PlatformUserController struct { beego.Controller } @@ -97,4 +97,3 @@ func (c *PlatformUserController) AddUser() { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败,请重试"} _ = c.ServeJSON() } - diff --git a/main.go b/main.go index 884df43..e6212eb 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "strings" + "server/models" _ "server/routers" "server/version" @@ -18,6 +20,11 @@ func main() { beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) { method := ctx.Input.Method() if method == "PUT" || method == "POST" || method == "PATCH" { + uri := ctx.Request.URL.Path + // 大文件 multipart 不能先 CopyBody 截断,否则上传解析失败 + if strings.Contains(uri, "/uploadfile") || strings.Contains(uri, "/uploadfiles") || strings.Contains(uri, "/uploadavatar") { + return + } ctx.Input.CopyBody(1024 * 1024) // 1MB 缓冲区 } }) diff --git a/models/admin_role.go b/models/admin_role.go index 06b0466..8960346 100644 --- a/models/admin_role.go +++ b/models/admin_role.go @@ -2,7 +2,7 @@ package models import "time" -// AdminRole 平台角色表 yz_admin_role +// AdminRole 平台角色表 yz_system_admin_role type AdminRole struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` Cid uint8 `orm:"column(cid);default(1)" json:"cid"` // 1平台角色 2租户角色 @@ -15,6 +15,5 @@ type AdminRole struct { } func (m *AdminRole) TableName() string { - return "yz_admin_role" + return "yz_system_admin_role" } - diff --git a/models/admin_user.go b/models/admin_user.go index ba30a05..9acea95 100644 --- a/models/admin_user.go +++ b/models/admin_user.go @@ -7,7 +7,7 @@ import ( "time" ) -// AdminUser 平台管理员信息表 yz_admin_user +// AdminUser 平台管理员信息表 yz_system_admin_user type AdminUser struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` Account string `orm:"column(account);size(64)" json:"account"` @@ -18,7 +18,7 @@ type AdminUser struct { Qq *string `orm:"column(qq);size(16);null" json:"qq"` Sex uint8 `orm:"column(sex);default(0)" json:"sex"` Avatar *string `orm:"column(avatar);size(255);null" json:"avatar"` - RoleID uint64 `orm:"column(role_id)" json:"group_id"` + RoleID uint64 `orm:"column(role_id)" json:"rid"` LoginCount uint64 `orm:"column(login_count);default(0)" json:"login_count"` LastLoginIP *string `orm:"column(last_login_ip);size(255);null" json:"last_login_ip"` Status uint8 `orm:"column(status);default(1)" json:"status"` @@ -28,7 +28,7 @@ type AdminUser struct { } func (m *AdminUser) TableName() string { - return "yz_admin_user" + return "yz_system_admin_user" } func md5Hex(s string) string { @@ -43,7 +43,7 @@ func NormalizeAccount(s string) string { // CreateAdminUser 创建平台管理员用户(password 会被 md5) func CreateAdminUser(account, password string, name, phone, email, qq, avatar *string, sex uint8, roleID uint64, status uint8) (uint64, error) { u := &AdminUser{ - Account: NormalizeAccount(account), + Account: NormalizeAccount(account), Password: md5Hex(strings.TrimSpace(password)), Name: name, Phone: phone, @@ -93,4 +93,3 @@ func ListAdminUsers() ([]AdminUser, int64, error) { _, err = Orm.QueryTable(new(AdminUser)).OrderBy("-id").All(&rows) return rows, total, err } - diff --git a/models/init.go b/models/init.go index 436e15c..74da700 100644 --- a/models/init.go +++ b/models/init.go @@ -3,8 +3,8 @@ package models import ( "fmt" - beego "github.com/beego/beego/v2/server/web" "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" _ "github.com/go-sql-driver/mysql" ) @@ -38,9 +38,13 @@ func Init(_ string) { new(SystemMenu), new(AdminUser), new(AdminRole), + new(SystemFile), + new(SystemFilesCategory), + new(SystemEmail), + new(SystemSMS), + new(SystemSMSTask), ) // 创建全局 Ormer Orm = orm.NewOrm() } - diff --git a/models/system_email.go b/models/system_email.go new file mode 100644 index 0000000..289dbfa --- /dev/null +++ b/models/system_email.go @@ -0,0 +1,23 @@ +package models + +import "time" + +// SystemEmail 系统邮箱配置表 yz_system_email +type SystemEmail struct { + ID uint `orm:"column(id);pk;auto" json:"id"` + FromAddress string `orm:"column(from_address);size(191)" json:"from_address"` + FromName *string `orm:"column(from_name);size(191);null" json:"from_name"` + Host string `orm:"column(host);size(191)" json:"host"` + Port uint `orm:"column(port);default(465)" json:"port"` + Password string `orm:"column(password);size(255)" json:"password"` + Encryption string `orm:"column(encryption);size(8)" json:"encryption"` // ssl / tls / none + Timeout uint `orm:"column(timeout);default(30)" json:"timeout"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark *string `orm:"column(remark);size(255);null" json:"remark"` + 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"` +} + +func (m *SystemEmail) TableName() string { + return "yz_system_email" +} diff --git a/models/system_file.go b/models/system_file.go new file mode 100644 index 0000000..396e74a --- /dev/null +++ b/models/system_file.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// SystemFile 附件表 yz_system_files +type SystemFile struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Uid *uint64 `orm:"column(uid);null" json:"uid"` + Tuid *uint64 `orm:"column(tuid);null" json:"tuid"` + Name string `orm:"column(name);size(255)" json:"name"` + Type uint8 `orm:"column(type);default(2)" json:"type"` + Cate uint64 `orm:"column(cate);default(0)" json:"cate"` + Size uint64 `orm:"column(size);default(0)" json:"size"` + Src string `orm:"column(src);size(512)" json:"src"` + Uploader uint64 `orm:"column(uploader);default(0)" json:"uploader"` + Md5 string `orm:"column(md5);size(32)" json:"md5"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemFile) TableName() string { + return "yz_system_files" +} diff --git a/models/system_files_category.go b/models/system_files_category.go new file mode 100644 index 0000000..23a499c --- /dev/null +++ b/models/system_files_category.go @@ -0,0 +1,19 @@ +package models + +import "time" + +// SystemFilesCategory 文件分类表 yz_system_files_category +type SystemFilesCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Uid *uint64 `orm:"column(uid);null" json:"uid"` + Tuid *uint64 `orm:"column(tuid);null" json:"tuid"` + Name string `orm:"column(name);size(128)" json:"name"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null;auto_now" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemFilesCategory) TableName() string { + return "yz_system_files_category" +} diff --git a/models/system_sms.go b/models/system_sms.go new file mode 100644 index 0000000..93d7418 --- /dev/null +++ b/models/system_sms.go @@ -0,0 +1,32 @@ +package models + +import "time" + +// SystemSMS 短信配置表 yz_system_sms +type SystemSMS struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ConfigCode string `orm:"column(config_code);size(64)" json:"config_code"` + ConfigName string `orm:"column(config_name);size(128)" json:"config_name"` + ChannelType int8 `orm:"column(channel_type);default(1)" json:"channel_type"` + + ApiURL string `orm:"column(api_url);size(512);default('')" json:"api_url"` + ApiKey string `orm:"column(api_key);size(256);default('')" json:"api_key"` + ApiSecret string `orm:"column(api_secret);size(256);default('')" json:"api_secret"` + + SignName string `orm:"column(sign_name);size(128);default('')" json:"sign_name"` + TemplateID string `orm:"column(template_id);size(128);default('')" json:"template_id"` + ExtraParams *string `orm:"column(extra_params);type(json);null" json:"extra_params"` + + TestPhone string `orm:"column(test_phone);size(64);default('')" json:"test_phone"` + + Weight int `orm:"column(weight);default(10)" json:"weight"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark string `orm:"column(remark);size(512);default('')" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"` +} + +func (m *SystemSMS) TableName() string { + return "yz_system_sms" +} diff --git a/models/system_sms_task.go b/models/system_sms_task.go new file mode 100644 index 0000000..f1b9a48 --- /dev/null +++ b/models/system_sms_task.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// SystemSMSTask 短信任务表 yz_system_sms_tasks +type SystemSMSTask struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid *uint64 `orm:"column(tid);null" json:"tid"` // 测试允许为空 + ApiKey string `orm:"column(api_key);size(255)" json:"api_key"` + Phone string `orm:"column(phone);size(50)" json:"phone"` + Content *string `orm:"column(content);type(text);null" json:"content"` + Status int `orm:"column(status);default(0)" json:"status"` + Code string `orm:"column(code);size(20);default('')" json:"code"` + ReportRaw *string `orm:"column(report_raw);type(text);null" json:"report_raw"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemSMSTask) TableName() string { + return "yz_system_sms_tasks" +} diff --git a/models/tenant.go b/models/tenant.go index 9109b7f..a78289f 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -2,25 +2,24 @@ package models import "time" -// Tenant 租户表 yz_tenant +// Tenant 租户表 yz_system_tenant type Tenant struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` // 租户唯一标识(主键) - TenantCode string `orm:"column(tenant_code);size(32);unique" json:"tenantCode"` // 租户编码 - TenantName string `orm:"column(tenant_name);size(128)" json:"tenantName"` // 租户名称 - ContactPerson string `orm:"column(contact_person);size(64);null" json:"contactPerson"` // 联系人 - ContactPhone string `orm:"column(contact_phone);size(20);null" json:"contactPhone"` // 联系电话 - ContactEmail string `orm:"column(contact_email);size(128);null" json:"contactEmail"` // 联系邮箱 - Address string `orm:"column(address);size(255);null" json:"address"` // 租户地址 - Worktime string `orm:"column(worktime);size(255);null" json:"worktime"` // 工作时间 - Status int8 `orm:"column(status);default(1)" json:"status"` // 租户状态:1-正常,2-停用,0-删除 - Remark string `orm:"column(remark);size(512);null" json:"remark"` // 备注信息 - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"` // 创建时间 - UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"updateTime"` // 更新时间 - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"` // 删除时间 + ID uint64 `orm:"column(id);pk;auto" json:"id"` // 租户唯一标识(主键) + TenantCode string `orm:"column(tenant_code);size(32);unique" json:"tenantCode"` // 租户编码 + TenantName string `orm:"column(tenant_name);size(128)" json:"tenantName"` // 租户名称 + ContactPerson string `orm:"column(contact_person);size(64);null" json:"contactPerson"` // 联系人 + ContactPhone string `orm:"column(contact_phone);size(20);null" json:"contactPhone"` // 联系电话 + ContactEmail string `orm:"column(contact_email);size(128);null" json:"contactEmail"` // 联系邮箱 + Address string `orm:"column(address);size(255);null" json:"address"` // 租户地址 + Worktime string `orm:"column(worktime);size(255);null" json:"worktime"` // 工作时间 + Status int8 `orm:"column(status);default(1)" json:"status"` // 租户状态:1-正常,2-停用,0-删除 + Remark string `orm:"column(remark);size(512);null" json:"remark"` // 备注信息 + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"` // 创建时间 + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"updateTime"` // 更新时间 + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"` // 删除时间 } // TableName 自定义表名 func (t *Tenant) TableName() string { - return "yz_tenant" + return "yz_system_tenant" } - diff --git a/models/tenant_user.go b/models/tenant_user.go index fc1a0cd..b033ded 100644 --- a/models/tenant_user.go +++ b/models/tenant_user.go @@ -2,27 +2,27 @@ package models import "time" -// TenantUser 租户用户绑定关系表 yz_tenant_user +// TenantUser 租户用户绑定关系表 yz_system_tenant_user type TenantUser struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` - Tid uint64 `orm:"column(tid)" json:"tid"` // 租户ID - Uid uint64 `orm:"column(uid)" json:"uid"` // 用户ID - Account *string `orm:"column(account);size(64);null" json:"account"` // 用户账号(冗余) - Name *string `orm:"column(name);size(64);null" json:"name"` // 用户名称(冗余) - Phone *string `orm:"column(phone);size(20);null" json:"phone"` // 手机号(冗余) - Email *string `orm:"column(email);size(128);null" json:"email"` // 邮箱(冗余) - Password *string `orm:"column(password);size(255);null" json:"password"` // 密码(冗余/可选) - IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 是否默认租户 - Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:1启用,0禁用 - Remark *string `orm:"column(remark);size(255);null" json:"remark"` - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` // 租户ID + Uid uint64 `orm:"column(uid)" json:"uid"` // 用户ID + Account *string `orm:"column(account);size(64);null" json:"account"` // 用户账号(冗余) + Name *string `orm:"column(name);size(64);null" json:"name"` // 用户名称(冗余) + Phone *string `orm:"column(phone);size(20);null" json:"phone"` // 手机号(冗余) + Email *string `orm:"column(email);size(128);null" json:"email"` // 邮箱(冗余) + Password *string `orm:"column(password);size(255);null" json:"password"` // 密码(冗余/可选) + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` // 是否默认租户 + Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:1启用,0禁用 + Remark *string `orm:"column(remark);size(255);null" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"` DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` } // TableName 自定义表名 func (m *TenantUser) TableName() string { - return "yz_tenant_user" + return "yz_system_tenant_user" } // BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值) @@ -103,4 +103,3 @@ func SetDefaultTenant(uid, tid uint64) error { Update(map[string]interface{}{"is_default": 1}) return err } - diff --git a/routers/platform/platform.go b/routers/platform/platform.go index 3670384..a7670cd 100644 --- a/routers/platform/platform.go +++ b/routers/platform/platform.go @@ -10,6 +10,7 @@ import ( func Register() { // 平台登录相关 beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:Login") + beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser") beego.Router("/platform/sendLoginCode", &controllers.PlatformAuthController{}, "post:SendLoginCode") beego.Router("/platform/loginBySms", &controllers.PlatformAuthController{}, "post:LoginBySms") beego.Router("/platform/logout", &controllers.PlatformAuthController{}, "post:Logout") @@ -47,7 +48,7 @@ func Register() { beego.Router("/platform/tenantUser/edit/:id", &controllers.PlatformTenantUserController{}, "post:EditTenantUser") beego.Router("/platform/tenantUser/delete/:id", &controllers.PlatformTenantUserController{}, "delete:DeleteTenantUser") - // 平台管理员用户管理(yz_admin_user) + // 平台管理员用户管理(yz_system_admin_user) beego.Router("/platform/getAllUsers", &controllers.PlatformAdminUserController{}, "get:GetAllUsers") beego.Router("/platform/getUserInfo/:id", &controllers.PlatformAdminUserController{}, "get:GetUserInfo") beego.Router("/platform/addUser", &controllers.PlatformAdminUserController{}, "post:AddUser") @@ -55,11 +56,42 @@ func Register() { beego.Router("/platform/deleteUser/:id", &controllers.PlatformAdminUserController{}, "delete:DeleteUser") beego.Router("/platform/changePassword", &controllers.PlatformAdminUserController{}, "post:ChangePassword") - // 平台角色管理(yz_admin_role) + // 平台角色管理(yz_system_admin_role) beego.Router("/platform/allRoles", &controllers.PlatformRoleController{}, "get:GetAllRoles") beego.Router("/platform/roles/:id", &controllers.PlatformRoleController{}, "get:GetRoleByID") beego.Router("/platform/roles", &controllers.PlatformRoleController{}, "post:CreateRole") beego.Router("/platform/roles/:id", &controllers.PlatformRoleController{}, "put:UpdateRole") beego.Router("/platform/roles/:id", &controllers.PlatformRoleController{}, "delete:DeleteRole") -} + // 系统邮箱配置(yz_system_email) + beego.Router("/platform/email/info", &controllers.PlatformEmailController{}, "get:GetInfo") + beego.Router("/platform/email/editinfo", &controllers.PlatformEmailController{}, "post:EditInfo") + beego.Router("/platform/email/sendtestemail", &controllers.PlatformEmailController{}, "post:SendTestEmail") + + // 短信配置(yz_system_sms) + beego.Router("/platform/sms/info", &controllers.PlatformSMSController{}, "get:GetSmsInfo") + beego.Router("/platform/sms/editinfo", &controllers.PlatformSMSController{}, "post:EditSmsInfo") + beego.Router("/platform/sms/sendtest", &controllers.PlatformSMSController{}, "post:SendTestSms") + beego.Router("/platform/sms/taskList", &controllers.PlatformSMSController{}, "get:GetSmsTaskList") + beego.Router("/platform/sms/taskEdit/:id", &controllers.PlatformSMSController{}, "post:EditSmsTask") + + // 文件管理(yz_system_files / yz_system_files_category) + beego.Router("/platform/usercate", &controllers.PlatformFileController{}, "get:GetUserCate") + beego.Router("/platform/allfiles", &controllers.PlatformFileController{}, "get:GetAllFiles") + beego.Router("/platform/catefiles/:id", &controllers.PlatformFileController{}, "get:GetCateFiles") + beego.Router("/platform/file/:id", &controllers.PlatformFileController{}, "get:GetFileByID") + beego.Router("/platform/deletefilepermanently/:id", &controllers.PlatformFileController{}, "delete:DeleteFilePermanently") + beego.Router("/platform/uploadfile", &controllers.PlatformFileController{}, "post:UploadFile") + beego.Router("/platform/uploadfiles", &controllers.PlatformFileController{}, "post:UploadFile") + beego.Router("/platform/updatefile/:id", &controllers.PlatformFileController{}, "post:UpdateFile") + beego.Router("/platform/deletefile/:id", &controllers.PlatformFileController{}, "delete:DeleteFile") + beego.Router("/platform/movefile/:id", &controllers.PlatformFileController{}, "get:MoveFile") + beego.Router("/platform/createfilecate", &controllers.PlatformFileController{}, "post:CreateFileCate") + beego.Router("/platform/renamefilecate/:id", &controllers.PlatformFileController{}, "post:RenameFileCate") + beego.Router("/platform/deletefilecate/:id", &controllers.PlatformFileController{}, "delete:DeleteFileCate") + beego.Router("/platform/uploadavatar", &controllers.PlatformFileController{}, "post:UploadAvatar") + beego.Router("/platform/uploadavatar/:id", &controllers.PlatformFileController{}, "post:UpdateAvatar") + beego.Router("/platform/batchdeletefiles", &controllers.PlatformFileController{}, "post:BatchDeleteFiles") + beego.Router("/platform/batchDeleteFilesPermanently", &controllers.PlatformFileController{}, "post:BatchDeleteFilesPermanently") + beego.Router("/platform/batchMoveFiles", &controllers.PlatformFileController{}, "post:BatchMoveFiles") +} diff --git a/server.exe b/server.exe index 1e8b36a..243db64 100644 Binary files a/server.exe and b/server.exe differ diff --git a/services/platform_auth.go b/services/platform_auth.go index 0aa663c..afa4a9d 100644 --- a/services/platform_auth.go +++ b/services/platform_auth.go @@ -11,11 +11,43 @@ import ( ) type PlatformLoginUser struct { - ID uint64 - Account string - Name string - Rid uint64 - Avatar string + ID uint64 + Account string + Name string + Rid uint64 + Avatar string + RoleName string +} + +func adminRoleNameByID(roleID uint64) string { + if roleID == 0 { + return "" + } + var role models.AdminRole + err := models.Orm.QueryTable(new(models.AdminRole)).Filter("id", roleID).One(&role) + if err != nil { + return "" + } + return role.Name +} + +func toPlatformLoginUser(user *models.AdminUser) *PlatformLoginUser { + name := "" + if user.Name != nil { + name = *user.Name + } + avatar := "" + if user.Avatar != nil { + avatar = *user.Avatar + } + return &PlatformLoginUser{ + ID: user.ID, + Account: user.Account, + Name: name, + Rid: user.RoleID, + Avatar: avatar, + RoleName: adminRoleNameByID(user.RoleID), + } } func md5Hex(s string) string { @@ -23,7 +55,7 @@ func md5Hex(s string) string { return hex.EncodeToString(sum[:]) } -// PlatformLogin 平台登录业务(仅允许平台用户 yz_admin_user 登录) +// PlatformLogin 平台登录业务(仅允许平台用户 yz_system_admin_user 登录) func PlatformLogin(account, password string) (string, *PlatformLoginUser, error) { account = strings.TrimSpace(account) password = strings.TrimSpace(password) @@ -52,21 +84,18 @@ func PlatformLogin(account, password string) (string, *PlatformLoginUser, error) return "", nil, err } - name := "" - if user.Name != nil { - name = *user.Name - } - avatar := "" - if user.Avatar != nil { - avatar = *user.Avatar - } - loginUser := &PlatformLoginUser{ - ID: user.ID, - Account: user.Account, - Name: name, - Rid: user.RoleID, - Avatar: avatar, - } + loginUser := toPlatformLoginUser(&user) return token, loginUser, nil } +// PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称) +func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) { + u, err := models.GetAdminUserByID(uid) + if err != nil { + return nil, errors.New("用户不存在") + } + if u.Status == 0 { + return nil, errors.New("账号已禁用") + } + return toPlatformLoginUser(u), nil +} diff --git a/services/system_email_smtp.go b/services/system_email_smtp.go new file mode 100644 index 0000000..9d4e902 --- /dev/null +++ b/services/system_email_smtp.go @@ -0,0 +1,126 @@ +package services + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strconv" + "strings" + "time" +) + +// SMTPConfig 发送邮件所需参数(与 yz_system_email 字段对应) +type SMTPConfig struct { + FromAddress string + FromName string + Host string + Port uint + Password string + Encryption string // ssl / tls / none + Timeout uint // 秒 +} + +// SendTestEmailSMTP 发送一封简单测试邮件(纯文本 UTF-8) +func SendTestEmailSMTP(cfg SMTPConfig, to string) error { + to = strings.TrimSpace(to) + if to == "" { + return fmt.Errorf("收件人不能为空") + } + if cfg.Host == "" || cfg.FromAddress == "" { + return fmt.Errorf("SMTP 主机或发件人不能为空") + } + if cfg.Port == 0 { + cfg.Port = 465 + } + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 + } + d := net.Dialer{Timeout: time.Duration(timeout) * time.Second} + addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10)) + enc := strings.ToLower(strings.TrimSpace(cfg.Encryption)) + if enc == "" { + enc = "ssl" + } + + var client *smtp.Client + var err error + + switch enc { + case "ssl": + conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + case "tls": + conn, derr := d.Dial("tcp", addr) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { + _ = client.Close() + return fmt.Errorf("STARTTLS 失败: %w", err) + } + } + case "none": + conn, derr := d.Dial("tcp", addr) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + default: + return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption) + } + defer func() { _ = client.Close() }() + + auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host) + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP 认证失败: %w", err) + } + if err = client.Mail(cfg.FromAddress); err != nil { + return fmt.Errorf("MAIL FROM 失败: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO 失败: %w", err) + } + wc, err := client.Data() + if err != nil { + return fmt.Errorf("DATA 失败: %w", err) + } + fromName := strings.TrimSpace(cfg.FromName) + subject := "平台邮箱测试" + body := "这是一封来自管理后台「邮箱管理」的测试邮件。\r\nThis is a test email from the platform email settings.\r\n" + headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n", + formatFromHeader(fromName, cfg.FromAddress), to, subject) + if _, err = wc.Write([]byte(headers + body)); err != nil { + return fmt.Errorf("写入邮件内容失败: %w", err) + } + if err = wc.Close(); err != nil { + return fmt.Errorf("结束 DATA 失败: %w", err) + } + return client.Quit() +} + +func formatFromHeader(name, addr string) string { + name = strings.TrimSpace(name) + if name == "" { + return addr + } + return fmt.Sprintf("%s <%s>", name, addr) +} diff --git a/services/system_email_store.go b/services/system_email_store.go new file mode 100644 index 0000000..7defd3d --- /dev/null +++ b/services/system_email_store.go @@ -0,0 +1,77 @@ +package services + +import ( + "fmt" + "strings" + + "server/models" +) + +// ListSystemEmails 返回全部邮箱配置(按 id 升序,通常仅一条) +func ListSystemEmails() ([]models.SystemEmail, error) { + var rows []models.SystemEmail + _, err := models.Orm.QueryTable(new(models.SystemEmail)).OrderBy("id").All(&rows) + return rows, err +} + +// UpsertFirstSystemEmail 若已有记录则更新第一条,否则插入 +func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, port uint, password string, encryption string, timeout uint, status int8, remark *string) error { + if encryption == "" { + encryption = "ssl" + } + if port == 0 { + port = 465 + } + if timeout == 0 { + timeout = 30 + } + if status == 0 { + status = 1 + } + fromAddress = strings.TrimSpace(fromAddress) + host = strings.TrimSpace(host) + + cnt, err := models.Orm.QueryTable(new(models.SystemEmail)).Count() + if err != nil { + return err + } + if cnt == 0 { + if strings.TrimSpace(password) == "" { + return fmt.Errorf("首次保存必须填写授权码/密码") + } + row := &models.SystemEmail{ + FromAddress: fromAddress, + FromName: fromName, + Host: host, + Port: port, + Password: strings.TrimSpace(password), + Encryption: encryption, + Timeout: timeout, + Status: status, + Remark: remark, + } + _, err = models.Orm.Insert(row) + return err + } + + var first models.SystemEmail + if err := models.Orm.QueryTable(new(models.SystemEmail)).OrderBy("id").Limit(1).One(&first); err != nil { + return err + } + + up := map[string]interface{}{ + "from_address": fromAddress, + "from_name": fromName, + "host": host, + "port": port, + "encryption": encryption, + "timeout": timeout, + "status": status, + "remark": remark, + } + if strings.TrimSpace(password) != "" { + up["password"] = strings.TrimSpace(password) + } + _, err = models.Orm.QueryTable(new(models.SystemEmail)).Filter("id", first.ID).Update(up) + return err +}