package controllers import ( "strconv" "strings" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "server/services" "github.com/beego/beego/v2/client/orm" ) // 移动端员工档案「全公司视角」接口(HR 使用)。 // // 与 Web 端(/backend/oa/employeefile/*)的关系: // - 数据口径完全一致:一人一档(yz_backend_employee_file)+ 教育/工作/合同/证照子记录, // DTO 组装直接复用 Web 端 efAssembleDTOList,保证两端展示字段完全一致; // - 只读:建档、改档、离职等写操作仍只在 Web 端提供; // - 数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。 // // 权限口径:租户角色的 rights 里勾选了「员工档案」菜单(或其祖先节点)即视为 HR,可见全公司档案; // 租户管理员 / 未分配角色 / 角色未配置权限等既有「全权限」情形同样放行。 // 未放行的用户仍可查看本人档案(/api/oa/employee-file),前端据此展示不同视图。 // employeeFileMenuIDs 命中「员工档案」菜单的菜单ID集合(含其祖先节点)。 // 菜单表由租户自行维护,标题/路径可能被改写,因此按 路径关键字 + 标题关键字 双重匹配, // 与移动端菜单跳转规则(uniapp/utils/nav.js 中的 employeefile 规则)保持同一识别口径。 func employeeFileMenuIDs() map[uint64]bool { ids := map[uint64]bool{} if models.Orm == nil { return ids } var rows []models.SystemMenu if _, err := models.Orm.QueryTable(new(models.SystemMenu)). Filter("status", 1). Filter("delete_time__isnull", true). All(&rows); err != nil { return ids } pidOf := make(map[uint64]int64, len(rows)) hit := map[uint64]bool{} for _, m := range rows { pidOf[m.ID] = m.Pid hay := strings.ToLower(m.Title + " " + derefString(m.Path) + " " + derefString(m.ComponentPath)) if strings.Contains(hay, "employeefile") || strings.Contains(hay, "employee-file") || strings.Contains(m.Title, "员工档案") || strings.Contains(m.Title, "人事档案") { hit[m.ID] = true } } if len(hit) == 0 { return ids } // 角色权限勾到父目录即代表含全部下级,因此把命中菜单的祖先一并纳入判定。 for id := range hit { cur := id for i := 0; i < 32; i++ { // 限制深度,防御脏数据造成的环 if _, ok := pidOf[cur]; !ok { break } ids[cur] = true pid := pidOf[cur] if pid <= 0 { break } cur = uint64(pid) } } return ids } // canViewAllEmployeeFiles 判断当前用户是否有权查看全公司员工档案(HR 视角)。 func canViewAllEmployeeFiles(claims *jwtutil.Claims) bool { if claims == nil || claims.TenantId <= 0 { return false } // 平台管理员 if claims.UserType == "platform" { return true } // 租户管理员 / 未分配角色 / 角色未配置权限 —— 与 Web 端 isTenantAdmin 同一口径 if isTenantAdmin(claims.UserID, claims.TenantId) { return true } var tu models.SystemTenantUser if models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("uid", tenantUIDOf(claims)). Filter("tid", claims.TenantId). Filter("delete_time__isnull", true).One(&tu) != nil { return false } if tu.GroupID == 0 { return true } var role models.AdminRole if models.Orm.QueryTable(new(models.AdminRole)). Filter("id", tu.GroupID). Filter("cid", 2).One(&role) != nil { return false } if strings.TrimSpace(role.Name) == "租户管理员" { return true } rights := "" if role.Rights != nil { rights = strings.TrimSpace(*role.Rights) } if rights == "" || rights == "[]" || rights == "null" { return true } allowed := services.ParseMenuRights(rights) if len(allowed) == 0 { return true } for id := range employeeFileMenuIDs() { if allowed[id] { return true } } return false } // tenantUIDOf 令牌 user_id 是认证中心 identity_id,换算成本租户的老 uid。 func tenantUIDOf(claims *jwtutil.Claims) uint64 { return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID)) } // EmployeeFileAccess GET /api/oa/employee-file/access // 返回当前用户是否具备 HR 视角(可见全公司档案),前端据此决定展示列表还是本人档案。 func (c *AppOaController) EmployeeFileAccess() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } c.oaOk(map[string]interface{}{"can_view_all": canViewAllEmployeeFiles(claims)}) } // EmployeeFileList GET /api/oa/employee-file/list 全公司档案分页列表(需 HR 权限)。 // 支持 keyword(档案编号/身份证/员工姓名/账号/手机)、status(在职状态)、org_id(部门,含下级)。 func (c *AppOaController) EmployeeFileList() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } if !canViewAllEmployeeFiles(claims) { c.oaErr(403, 403, "您没有查看全部员工档案的权限") return } tid := claims.TenantId page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) keyword := strings.TrimSpace(c.GetString("keyword")) statusRaw := strings.TrimSpace(c.GetString("status")) orgID, _ := c.GetUint64("org_id") if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } web := &BackendEmployeeFileController{} qs := web.efQuery(tid) if orgID > 0 { empIDs := web.efEmployeeIDsInOrgs(tid, web.efSubtreeOrgIDs(tid, orgID)) if len(empIDs) == 0 { c.oaOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0, "page": page, "pageSize": pageSize}) return } qs = qs.Filter("employee_id__in", empIDs) } if statusRaw != "" { status, convErr := strconv.Atoi(statusRaw) if convErr != nil || status < 1 || status > 3 { c.oaErr(400, 400, "在职状态参数无效") return } qs = qs.Filter("employment_status", int8(status)) } if keyword != "" { // 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机) empIDs := web.efEmployeeIDsByKeyword(tid, keyword) cond := orm.NewCondition(). Or("file_no__icontains", keyword). Or("id_card__icontains", keyword) if len(empIDs) > 0 { cond = cond.Or("employee_id__in", empIDs) } qs = qs.SetCond(orm.NewCondition().AndCond(cond)) } total, _ := qs.Count() var files []models.BackendEmployeeFile if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&files); err != nil && err != orm.ErrNoRows { c.oaErr(500, 500, "查询失败") return } c.oaOk(map[string]interface{}{ "list": web.efAssembleDTOList(tid, files), "total": total, "page": page, "pageSize": pageSize, }) } // EmployeeFileDetail GET /api/oa/employee-file/detail/:id 员工档案详情(含子记录)。 // // 两种访问路径: // - HR 视角:可查看任意员工的档案(租户内隔离); // - 普通员工:仅可查看本人档案,看他人档案返回 403。 func (c *AppOaController) EmployeeFileDetail() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } tid := claims.TenantId id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的档案ID") return } web := &BackendEmployeeFileController{} var file models.BackendEmployeeFile if web.efQuery(tid).Filter("id", id).One(&file) != nil { c.oaErr(404, 404, "档案不存在") return } if !canViewAllEmployeeFiles(claims) { emp, ok := c.oaMyEmployee(claims) if !ok || uint64(emp.ID) != file.EmployeeID { c.oaErr(403, 403, "您没有查看他人档案的权限") return } } var records []models.BackendEmployeeFileRecord if _, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)). Filter("tid", tid). Filter("is_deleted", 0). Filter("employee_id", file.EmployeeID). OrderBy("sort", "id"). All(&records); err != nil && err != orm.ErrNoRows { c.oaErr(500, 500, "查询档案记录失败") return } if records == nil { records = []models.BackendEmployeeFileRecord{} } dtos := web.efAssembleDTOList(tid, []models.BackendEmployeeFile{file}) c.oaOk(map[string]interface{}{"file": dtos[0], "records": records}) }