283 lines
7.5 KiB
Go
283 lines
7.5 KiB
Go
package dailyreport
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/services"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
)
|
||
|
||
// 日报补写窗口:含今天在内的最近 7 天。
|
||
const BackfillDays = 7
|
||
|
||
// 业务错误:控制器按 Code 决定 HTTP 状态码与提示。
|
||
type Error struct {
|
||
Code int
|
||
Msg string
|
||
}
|
||
|
||
func (e *Error) Error() string { return e.Msg }
|
||
|
||
func errBad(msg string) error { return &Error{Code: 400, Msg: msg} }
|
||
func errForbidden(msg string) error { return &Error{Code: 403, Msg: msg} }
|
||
func errNotFound(msg string) error { return &Error{Code: 404, Msg: msg} }
|
||
|
||
// StatusOf 取业务错误的 HTTP 状态码,非业务错误回退 fallback。
|
||
func StatusOf(err error, fallback int) int {
|
||
var e *Error
|
||
if errors.As(err, &e) {
|
||
return e.Code
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// UserContext 当前操作人在日报模块内的身份与可见范围。
|
||
//
|
||
// UID 是 yz_system_tenant_user.uid(老 uid),不是 JWT 的 identity_id。
|
||
// TeamOrgIDs 是「当前用户作为负责人的部门」及其全部下级部门;
|
||
// 为空且非管理员时,只能查看/操作自己的日报。
|
||
type UserContext struct {
|
||
Tid uint64
|
||
UID uint64
|
||
Name string
|
||
OrgID uint64
|
||
OrgName string
|
||
IsAdmin bool
|
||
TeamOrgIDs []uint64
|
||
CanViewTeam bool
|
||
}
|
||
|
||
// InTeamOrg 判断部门是否落在当前用户的团队范围内。
|
||
func (u *UserContext) InTeamOrg(orgID uint64) bool {
|
||
for _, id := range u.TeamOrgIDs {
|
||
if id == orgID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Today 本地日期 "YYYY-MM-DD"。
|
||
func Today() string {
|
||
return time.Now().Format("2006-01-02")
|
||
}
|
||
|
||
// WindowStart 补写窗口起点(今天往前 BackfillDays-1 天)。
|
||
func WindowStart() string {
|
||
return time.Now().AddDate(0, 0, -(BackfillDays - 1)).Format("2006-01-02")
|
||
}
|
||
|
||
// InWindow 判断日期是否在补写窗口内。
|
||
func InWindow(date string) bool {
|
||
return date >= WindowStart() && date <= Today()
|
||
}
|
||
|
||
// ParseDate 校验 "YYYY-MM-DD" 并返回规范化的字符串。
|
||
func ParseDate(date string) (string, error) {
|
||
date = strings.TrimSpace(date)
|
||
if date == "" {
|
||
return Today(), nil
|
||
}
|
||
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||
if err != nil {
|
||
return "", errBad("日报日期格式应为 YYYY-MM-DD")
|
||
}
|
||
return t.Format("2006-01-02"), nil
|
||
}
|
||
|
||
// dateOnly 把驱动返回的 DATE 值统一成 "YYYY-MM-DD"。
|
||
// go-sql-driver 在 parseTime=true 时把 DATE 读成 time.Time,
|
||
// Beego ORM 赋给 string 字段后会得到 "2026-10-21 00:00:00 +0800 CST",
|
||
// 直接参与 InWindow 的字符串比较会误判为“不在窗口内”。
|
||
func dateOnly(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if len(s) >= 10 {
|
||
return s[:10]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// BuildContext 组装当前用户的日报身份与团队范围。
|
||
// identityUserID 为 JWT 中的 UserID,platformAdmin 表示平台端管理员。
|
||
func BuildContext(tid uint64, identityUserID uint64, fallbackName string, platformAdmin bool) (*UserContext, error) {
|
||
if models.Orm == nil || tid == 0 {
|
||
return nil, errBad("缺少租户信息")
|
||
}
|
||
uid := services.ResolveTenantUID(tid, identityUserID)
|
||
if uid == 0 {
|
||
return nil, errBad("无法识别当前用户")
|
||
}
|
||
|
||
u := &UserContext{Tid: tid, UID: uid, Name: strings.TrimSpace(fallbackName), IsAdmin: platformAdmin}
|
||
|
||
var tu models.SystemTenantUser
|
||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||
Filter("tid", tid).
|
||
Filter("uid", uid).
|
||
Filter("delete_time__isnull", true).
|
||
One(&tu)
|
||
if err == nil {
|
||
u.OrgID = tu.OrgID
|
||
if n := strings.TrimSpace(ptrString(tu.Name)); n != "" {
|
||
u.Name = n
|
||
} else if a := strings.TrimSpace(ptrString(tu.Account)); a != "" {
|
||
u.Name = a
|
||
}
|
||
if !u.IsAdmin {
|
||
u.IsAdmin = tenantAdmin(tid, &tu)
|
||
}
|
||
} else if err != orm.ErrNoRows {
|
||
return nil, err
|
||
}
|
||
if u.Name == "" {
|
||
u.Name = fmt.Sprintf("用户%d", uid)
|
||
}
|
||
|
||
orgs, err := loadOrgs(tid)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
|
||
for i := range orgs {
|
||
orgByID[orgs[i].ID] = &orgs[i]
|
||
}
|
||
if o := orgByID[u.OrgID]; o != nil {
|
||
u.OrgName = o.OrgName
|
||
}
|
||
|
||
if !u.IsAdmin {
|
||
leaderKeys := leaderKeys(tid, &tu, identityUserID, uid)
|
||
roots := make([]uint64, 0, 2)
|
||
for i := range orgs {
|
||
if orgs[i].LeaderID != nil && containsID(leaderKeys, *orgs[i].LeaderID) {
|
||
roots = append(roots, orgs[i].ID)
|
||
}
|
||
}
|
||
u.TeamOrgIDs = collectSubtree(roots, orgs)
|
||
}
|
||
u.CanViewTeam = u.IsAdmin || len(u.TeamOrgIDs) > 0
|
||
return u, nil
|
||
}
|
||
|
||
// tenantAdmin 与 controllers.isTenantAdmin 判定口径一致:
|
||
// 未分配角色 / 租户管理员角色 / 角色未配置权限,均视为全权限。
|
||
//
|
||
// 角色查询额外兼容全局预置角色:「租户管理员」「租户用户」在
|
||
// yz_system_admin_role 中是 cid=2 且 tenant_id=0 的全局记录,
|
||
// 只有租户自建角色才带自己的 tenant_id,因此这里同时接受 0 与本租户。
|
||
func tenantAdmin(tid uint64, tu *models.SystemTenantUser) bool {
|
||
if tu.GroupID == 0 {
|
||
return true
|
||
}
|
||
cond := orm.NewCondition().
|
||
And("id", tu.GroupID).
|
||
And("cid", 2).
|
||
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tid))
|
||
var role models.AdminRole
|
||
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
|
||
return false
|
||
}
|
||
if role.Name == "租户管理员" {
|
||
return true
|
||
}
|
||
rights := ""
|
||
if role.Rights != nil {
|
||
rights = strings.TrimSpace(*role.Rights)
|
||
}
|
||
return rights == "" || rights == "[]" || rights == "null"
|
||
}
|
||
|
||
// leaderKeys 负责人在库里可能存员工ID,也可能存租户用户 uid / 认证中心 uid,
|
||
// 这里把三种可能都收进来,避免因历史数据差异漏判主管身份。
|
||
func leaderKeys(tid uint64, tu *models.SystemTenantUser, identityUserID, uid uint64) []uint64 {
|
||
keys := []uint64{uid}
|
||
if identityUserID > 0 && identityUserID != uid {
|
||
keys = append(keys, identityUserID)
|
||
}
|
||
account := strings.TrimSpace(ptrString(tu.Account))
|
||
if account == "" {
|
||
return keys
|
||
}
|
||
var emps []models.BackendEmployee
|
||
if _, err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
||
Filter("tid", tid).
|
||
Filter("account", account).
|
||
Filter("delete_time__isnull", true).
|
||
All(&emps); err != nil {
|
||
return keys
|
||
}
|
||
for _, e := range emps {
|
||
keys = append(keys, uint64(e.ID))
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// loadOrgs 读取租户内未删除的部门。
|
||
func loadOrgs(tid uint64) ([]models.BackendOrganization, error) {
|
||
var orgs []models.BackendOrganization
|
||
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||
Filter("tid", tid).
|
||
Filter("delete_time__isnull", true).
|
||
OrderBy("sort", "id").
|
||
All(&orgs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return orgs, nil
|
||
}
|
||
|
||
// collectSubtree 返回 roots 及其全部下级部门 ID。
|
||
func collectSubtree(roots []uint64, orgs []models.BackendOrganization) []uint64 {
|
||
if len(roots) == 0 {
|
||
return nil
|
||
}
|
||
children := make(map[uint64][]uint64, len(orgs))
|
||
for _, o := range orgs {
|
||
if o.ParentID > 0 {
|
||
children[o.ParentID] = append(children[o.ParentID], o.ID)
|
||
}
|
||
}
|
||
seen := make(map[uint64]bool, len(roots))
|
||
queue := make([]uint64, 0, len(roots))
|
||
for _, id := range roots {
|
||
if id > 0 && !seen[id] {
|
||
seen[id] = true
|
||
queue = append(queue, id)
|
||
}
|
||
}
|
||
out := make([]uint64, 0, len(queue))
|
||
for len(queue) > 0 {
|
||
id := queue[0]
|
||
queue = queue[1:]
|
||
out = append(out, id)
|
||
for _, child := range children[id] {
|
||
if !seen[child] {
|
||
seen[child] = true
|
||
queue = append(queue, child)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func containsID(ids []uint64, id uint64) bool {
|
||
for _, v := range ids {
|
||
if v == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func ptrString(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return *p
|
||
}
|