package controllers // ==================== 小程序新用户引导 ==================== // // 新的工作台口径:不再区分「登录 / 免登录」。 // 1. 点任意模块卡片 → 微信手机号授权; // 2. 手机号已存在 → 前端走 /auth/wechat/miniapp/login 静默登录(自动绑定 openid); // 不存在 → 本文件 Phone 签发注册票据,Register 收 ≥6 位密码后建号并绑定微信, // 同时签发 tid=0 的正式令牌(注册即登录,可直接使用原免登录模块); // 3. 进入需企业模块前,Status 查企业绑定 / 申请状态;未通过则走创建企业或挂靠企业; // 4. 两类申请复用 services 层已有的审核闭环(platform / backend 审批端无需改动)。 // // 本组路由均为公开路径(不在 middleware.protectedPrefixes 内): // phone / register 由微信凭证与一次性注册票据自证,其余端点自行校验 Bearer 令牌。 import ( "encoding/json" "io" "strconv" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "server/pkg/passwordutil" "server/services" authsvc "server/services/auth" "server/services/wechatmini" "github.com/beego/beego/v2/core/logs" beego "github.com/beego/beego/v2/server/web" ) const onboardingClientID = "yz-miniapp" type OnboardingController struct { beego.Controller } func (c *OnboardingController) reply(status int, msg string, data interface{}) { c.Ctx.Output.SetStatus(status) c.Data["json"] = map[string]interface{}{"code": status, "msg": msg, "data": data} _ = c.ServeJSON() } func (c *OnboardingController) ok(data interface{}) { c.reply(200, "success", data) } func (c *OnboardingController) fail(status int, msg string) { c.reply(status, msg, nil) } func (c *OnboardingController) readJSON(v interface{}) bool { body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } if err := json.Unmarshal(body, v); err != nil { c.fail(400, "参数错误") return false } return true } // claims 当前登录态;允许 tid=0(刚注册、尚未加入企业)的正式令牌。 func (c *OnboardingController) claims() (*jwtutil.Claims, error) { return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp) } func (c *OnboardingController) mustClaims() *jwtutil.Claims { cl, err := c.claims() if err != nil || cl == nil || cl.UserID <= 0 { c.fail(401, "登录态已失效,请重新授权手机号") return nil } return cl } // Phone POST /api/onboarding/phone // body: { code(wx.login), phone_code(getPhoneNumber), appid? } // 返回 exists / need_register;无账号时附带一次性 register_token(内含微信手机号与 openid)。 func (c *OnboardingController) Phone() { var req struct { Code string `json:"code"` PhoneCode string `json:"phone_code"` AppID string `json:"appid"` } if !c.readJSON(&req) { return } cfg, err := loadGuestMiniConfig(req.AppID) if err != nil { c.fail(400, "小程序未配置,无法获取手机号") return } phone, perr := wechatmini.GetPhoneNumber(cfg, strings.TrimSpace(req.PhoneCode)) if perr != nil { msg := perr.Error() if hint, ok := wechatmini.ExplainError(perr); ok { msg = hint } logs.Warn("[onboarding] 获取手机号失败: %v", perr) c.fail(400, "手机号授权失败:"+msg) return } mobile := strings.TrimSpace(phone.PurePhoneNumber) if mobile == "" { mobile = strings.TrimSpace(phone.PhoneNumber) } if mobile == "" { c.fail(400, "未获取到手机号") return } // 已注册:交给前端用现有小程序一键登录静默登录(自动补绑 openid) if exist := identityByPhone(mobile); exist != nil { c.ok(map[string]interface{}{ "exists": true, "need_register": false, "mobile_mask": maskPhone(mobile), }) return } // 注册必须同时绑定微信号:拿不到 openid 时不签发注册票据,避免留下「无绑定」账号 session := guestMiniSession(cfg, req.Code) if session == nil || strings.TrimSpace(session.OpenID) == "" { c.fail(400, "微信身份获取失败,请重新进入小程序授权") return } token := services.IssuePendingRegisterToken(services.RegisterPending{ Mobile: mobile, OpenID: session.OpenID, UnionID: session.UnionID, Provider: models.IdPWechatMini, }) if token == "" { c.fail(500, "注册会话创建失败,请稍后重试") return } c.ok(map[string]interface{}{ "exists": false, "need_register": true, "register_token": token, "mobile_mask": maskPhone(mobile), }) } // Register POST /api/onboarding/register // body: { register_token, password, confirm_password } // 密码最少 6 位;复用 CompleteRegister 建号 + 绑定微信,随后签发 tid=0 正式令牌。 func (c *OnboardingController) Register() { var req struct { Token string `json:"register_token"` Password string `json:"password"` Confirm string `json:"confirm_password"` } if !c.readJSON(&req) { return } tk := strings.TrimSpace(req.Token) if tk == "" { c.fail(400, "注册会话已失效,请重新授权手机号") return } pwd := strings.TrimSpace(req.Password) if len(pwd) < 6 { c.fail(400, "密码长度不能少于6位") return } if pwd != strings.TrimSpace(req.Confirm) { c.fail(400, "两次输入的密码不一致") return } hashed, herr := passwordutil.Hash(pwd) if herr != nil { c.fail(500, "密码处理失败,请重试") return } var session models.AuthRegisterSession if err := models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", tk).One(&session); err != nil { c.fail(400, "注册会话已失效,请重新授权手机号") return } if time.Now().After(session.ExpireAt) { c.fail(400, "注册会话已过期,请重新授权手机号") return } num, uerr := models.Orm.QueryTable(new(models.AuthRegisterSession)). Filter("token", tk). Update(map[string]interface{}{ "password_hash": hashed, "password_algo": passwordutil.AlgoOf(hashed), "update_time": time.Now(), }) if uerr != nil || num == 0 { c.fail(400, "注册会话已失效,请重新授权手机号") return } identityID, err := services.CompleteRegister(tk) if err != nil { c.fail(400, err.Error()) return } // 注册会话一次性使用:账号已建,后续申请凭 Bearer 令牌 _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", tk).Delete() tokens, terr := c.issueNoTenantTokens(identityID, session.Mobile) if terr != nil { logs.Error("[onboarding] 签发无企业令牌失败: %v", terr) c.fail(500, "注册成功但登录态签发失败,请重新进入小程序") return } c.ok(map[string]interface{}{ "tokens": tokens, "need_tenant": true, "mobile_mask": maskPhone(session.Mobile), }) } // issueNoTenantTokens 注册即登录:签发 tid=0、userType=app 的正式令牌。 // /api/home 对 tid=0 返回公开模块与公开菜单,因此原「免登录」功能可直接使用。 func (c *OnboardingController) issueNoTenantTokens(identityID uint64, mobile string) (*authsvc.TokenPair, error) { sess, err := authsvc.CreateSession(authsvc.SessionInfo{ IdentityID: identityID, Tid: 0, ClientID: onboardingClientID, IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), LoginType: "wechat_phone", Amr: "wechat_phone", }) if err != nil { return nil, err } return authsvc.IssueTokens(authsvc.TokenIssue{ IdentityID: identityID, Tid: 0, ClientID: onboardingClientID, Sid: sess.Sid, Username: maskPhone(mobile), UserType: "app", Amr: "wechat_phone", }) } // Status GET /api/onboarding/status // 返回已绑定企业列表、申请记录、微信手机号(前端只读展示)。 // 「我的 → 企业绑定」用 tenants + applies 展示多租户与审核进度; // apply 保留最新一条,兼容首次加入企业的工作台分流逻辑。 func (c *OnboardingController) Status() { cl := c.mustClaims() if cl == nil { return } identityID := uint64(cl.UserID) var ai models.AuthIdentity mobile := "" if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID).Filter("delete_time__isnull", true).One(&ai); err == nil && ai.Mobile != nil { mobile = *ai.Mobile } // 一人多企业:返回全部启用中的绑定,默认企业排在最前 var binds []models.AuthTenantUser _, _ = models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("identity_id", identityID). Filter("status", 1). OrderBy("-is_default", "id"). All(&binds) tenants := make([]map[string]interface{}, 0, len(binds)) for _, b := range binds { name := "" var t models.SystemTenant if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", b.Tid).One(&t); err == nil { name = t.TenantName } tenants = append(tenants, map[string]interface{}{ "tid": b.Tid, "tenant_name": name, "is_default": b.IsDefault, }) } tenantName := "" tid := uint64(0) if len(tenants) > 0 { tenantName, _ = tenants[0]["tenant_name"].(string) tid = binds[0].Tid } // 最近 20 条申请:可同时存在多家企业的待审绑定申请 var rows []models.AuthTenantApply _, _ = models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("identity_id", identityID). Filter("delete_time__isnull", true). OrderBy("-id").Limit(20). All(&rows) applies := make([]map[string]interface{}, 0, len(rows)) for _, a := range rows { applies = append(applies, map[string]interface{}{ "id": a.ID, "type": a.Type, "status": a.Status, "tenant_name": a.TenantName, "reject_reason": a.RejectReason, "department_name": a.DepartmentName, "position_name": a.PositionName, "applicant_name": a.ApplicantName, "create_time": a.CreateTime, }) } var applyData interface{} if len(applies) > 0 { applyData = applies[0] } c.ok(map[string]interface{}{ "has_tenant": len(tenants) > 0, "tid": tid, "tenant_name": tenantName, "mobile": mobile, "mobile_mask": maskPhone(mobile), "tenants": tenants, "applies": applies, "apply": applyData, }) } // Tenants GET /api/onboarding/tenants?keyword=xxx // 挂靠企业:按企业全称搜索启用中的企业(复用注册页的企业联想)。 func (c *OnboardingController) Tenants() { items, err := services.SearchTenantsForRegister(c.GetString("keyword")) if err != nil { c.fail(500, "企业搜索失败") return } c.ok(map[string]interface{}{"list": items}) } // Departments GET /api/onboarding/departments?tid=xxx // 公开下拉:按企业返回组织(部门)列表。挂靠时用户尚未加入该企业,故不走 /backend 鉴权接口。 func (c *OnboardingController) Departments() { tid := c.uintParam("tid") if tid == 0 { c.fail(400, "请先选择企业") return } var rows []models.BackendOrganization _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("status", 1). Filter("delete_time__isnull", true). OrderBy("sort", "id"). All(&rows) if err != nil { c.fail(500, "部门列表读取失败") return } list := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { list = append(list, map[string]interface{}{ "id": r.ID, "name": r.OrgName, "parent_id": r.ParentID, "is_company": r.IsCompany, }) } c.ok(map[string]interface{}{"list": list}) } // Positions GET /api/onboarding/positions?tid=xxx&department_id=xxx func (c *OnboardingController) Positions() { tid := c.uintParam("tid") if tid == 0 { c.fail(400, "请先选择企业") return } qs := models.Orm.QueryTable(new(models.BackendPosition)). Filter("tid", tid). Filter("status", 1). Filter("delete_time__isnull", true) if deptID := c.uintParam("department_id"); deptID > 0 { qs = qs.Filter("department_id", deptID) } var rows []models.BackendPosition if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { c.fail(500, "职位列表读取失败") return } list := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { list = append(list, map[string]interface{}{ "id": r.ID, "name": r.PositionName, "department_id": r.DepartmentID, }) } c.ok(map[string]interface{}{"list": list}) } // ApplyJoin POST /api/onboarding/apply-join // body: { tid, applicant_name, department_id, department_name, position_id, position_name } // 手机号不接受前端传值:由后端从微信验证过的身份手机号写入 wechat_mobile。 func (c *OnboardingController) ApplyJoin() { cl := c.mustClaims() if cl == nil { return } // 一人可绑定多家企业:已加入企业也能继续申请绑定其他企业。 // 「是否已绑定该企业 / 是否重复待审」由 CreateJoinApply 内部按 tid 判定。 var req struct { Tid uint64 `json:"tid"` ApplicantName string `json:"applicant_name"` DepartmentID uint64 `json:"department_id"` DepartmentName string `json:"department_name"` PositionID uint64 `json:"position_id"` PositionName string `json:"position_name"` } if !c.readJSON(&req) { return } if req.Tid == 0 || strings.TrimSpace(req.ApplicantName) == "" || req.DepartmentID == 0 || req.PositionID == 0 { c.fail(400, "请填写姓名并选择企业、部门、职位") return } mobile := c.identityMobile(uint64(cl.UserID)) apply, err := services.CreateJoinApply(uint64(cl.UserID), req.Tid, services.JoinApplyDetail{ ApplicantName: req.ApplicantName, DepartmentID: req.DepartmentID, DepartmentName: req.DepartmentName, PositionID: req.PositionID, PositionName: req.PositionName, WechatMobile: mobile, }) if err != nil { c.fail(400, err.Error()) return } // 微信手机号单独落库,避免被表单联系人字段覆盖 _, _ = models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("id", apply.ID). Update(map[string]interface{}{"wechat_mobile": mobile}) c.ok(map[string]interface{}{"apply_id": apply.ID, "status": apply.Status}) } // ApplyCreate POST /api/onboarding/apply-create // 沿用 PC 注册页的完整字段与校验(信用代码 18 位、营业执照、法人、联系人、电话、邮箱必填)。 func (c *OnboardingController) ApplyCreate() { cl := c.mustClaims() if cl == nil { return } // 多租户:已有企业也允许再创建一家(同身份只允许一条待审创建申请,由 CreateTenantApply 校验) var req services.TenantCreateApplyInput if !c.readJSON(&req) { return } apply, err := services.CreateTenantApply(uint64(cl.UserID), req) if err != nil { c.fail(400, err.Error()) return } c.ok(map[string]interface{}{"apply_id": apply.ID, "status": apply.Status}) } func (c *OnboardingController) hasTenant(identityID uint64) bool { cnt, _ := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("identity_id", identityID). Filter("status", 1). Count() return cnt > 0 } func (c *OnboardingController) identityMobile(identityID uint64) string { var ai models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID).Filter("delete_time__isnull", true).One(&ai); err != nil { return "" } if ai.Mobile == nil { return "" } return *ai.Mobile } func (c *OnboardingController) uintParam(key string) uint64 { v, _ := strconv.ParseUint(strings.TrimSpace(c.GetString(key)), 10, 64) return v }