472 lines
14 KiB
Go
472 lines
14 KiB
Go
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
|
|
// 返回企业绑定、最新申请状态、微信手机号(前端只读展示)。
|
|
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).
|
|
All(&binds)
|
|
hasTenant := len(binds) > 0
|
|
tenantName := ""
|
|
tid := uint64(0)
|
|
if hasTenant {
|
|
tid = binds[0].Tid
|
|
var t models.SystemTenant
|
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&t); err == nil {
|
|
tenantName = t.TenantName
|
|
}
|
|
}
|
|
|
|
var applyData interface{}
|
|
var apply models.AuthTenantApply
|
|
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
|
Filter("identity_id", identityID).
|
|
Filter("delete_time__isnull", true).
|
|
OrderBy("-id").One(&apply); err == nil {
|
|
applyData = map[string]interface{}{
|
|
"id": apply.ID,
|
|
"type": apply.Type,
|
|
"status": apply.Status,
|
|
"tenant_name": apply.TenantName,
|
|
"reject_reason": apply.RejectReason,
|
|
"department_name": apply.DepartmentName,
|
|
"position_name": apply.PositionName,
|
|
"applicant_name": apply.ApplicantName,
|
|
}
|
|
}
|
|
|
|
c.ok(map[string]interface{}{
|
|
"has_tenant": hasTenant,
|
|
"tid": tid,
|
|
"tenant_name": tenantName,
|
|
"mobile": mobile,
|
|
"mobile_mask": maskPhone(mobile),
|
|
"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
|
|
}
|
|
if c.hasTenant(uint64(cl.UserID)) {
|
|
c.fail(400, "你已加入企业,无需重复申请")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if c.hasTenant(uint64(cl.UserID)) {
|
|
c.fail(400, "你已加入企业,无需重复申请")
|
|
return
|
|
}
|
|
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
|
|
}
|