package controllers // ==================== 游客(免登录模块)接口 ==================== // // POST /api/guest/mini-login 微信手机号授权换取游客令牌(公开,不在受保护前缀内) // POST /api/guest/merge 正式登录后把游客数据并入正式账号(需正式令牌) // GET /api/guest/data 通用游客数据列表(需游客令牌,新模块直接复用) // POST /api/guest/data 新增一条 // PUT /api/guest/data/:id 覆盖一条 // DELETE /api/guest/data/:id 软删一条 // // 便捷工具(记事本/日程/密码库)不走通用 data 接口:为保持前端零改动, // 由各自控制器按 identity.IsGuest 分流进 gueststore(见 app_notebook.go 等)。 import ( "encoding/json" "io" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" authsvc "server/services/auth" "server/services/gueststore" "server/services/wechatmini" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" beego "github.com/beego/beego/v2/server/web" ) // guestTokenTTL 游客令牌有效期。签发即授权一次,30 天内免打扰; // 过期后 nav 游客态会重新弹授权(微信手机号验证按次计费,不宜缩短)。 const guestTokenTTL = 30 * 24 * time.Hour type GuestController struct { beego.Controller } func (c *GuestController) gReply(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() } // loadGuestMiniConfig 与小程序登录同源:appid 指定则按多应用配置选择,否则取第一条启用配置。 func loadGuestMiniConfig(appID string) (*wechatmini.Config, error) { if appID = strings.TrimSpace(appID); appID != "" { return wechatmini.LoadEnabledAppByAppID(appID) } return wechatmini.LoadEnabledConfig() } // maskPhone 手机号脱敏:138****8000。游客令牌 Username 与响应里都不出现完整号码。 func maskPhone(phone string) string { phone = strings.TrimSpace(phone) if len(phone) < 7 { return phone } return phone[:3] + "****" + phone[len(phone)-4:] } // MiniLogin POST /api/guest/mini-login // body: { "code": "wx.login的code(可选,用于记录openid)", "phone_code": "getPhoneNumber动态code", "appid": "可选" } func (c *GuestController) MiniLogin() { var req struct { Code string `json:"code"` PhoneCode string `json:"phone_code"` AppID string `json:"appid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } if err := json.Unmarshal(body, &req); err != nil { c.gReply(400, "参数错误", nil) return } cfg, err := loadGuestMiniConfig(req.AppID) if err != nil { c.gReply(400, "小程序未配置,无法获取手机号", nil) return } // openid 仅作溯源与身份识别(已绑定过正式身份时按 openid 认人),拿不到不阻断游客签发 var openID, unionID string session := guestMiniSession(cfg, req.Code) if session != nil { openID, unionID = session.OpenID, session.UnionID } if strings.TrimSpace(req.PhoneCode) == "" { c.gReply(400, "缺少手机号授权凭证", nil) return } phone, perr := wechatmini.GetPhoneNumber(cfg, req.PhoneCode) if perr != nil { msg := perr.Error() if hint, ok := wechatmini.ExplainError(perr); ok { msg = hint } logs.Warn("[guest] 获取手机号失败: %v", perr) c.gReply(400, "手机号授权失败:"+msg, nil) return } mobile := strings.TrimSpace(phone.PurePhoneNumber) if mobile == "" { mobile = strings.TrimSpace(phone.PhoneNumber) } if mobile == "" { c.gReply(400, "未获取到手机号", nil) return } // 手机号已注册温新云账号(或该微信已绑定正式身份):直接签发正式令牌按登录处理。 // (与 /auth/wechat/miniapp/login 同一套识别与签发逻辑) if c.tryUserLogin(mobile, session) { return } guest, err := gueststore.Upsert(mobile, openID, unionID) if err != nil { logs.Error("[guest] upsert 失败: %v", err) c.gReply(500, "游客身份创建失败", nil) return } token, err := jwtutil.SignToken(jwtutil.TokenOptions{ UserID: int(guest.ID), Username: maskPhone(mobile), TenantID: 0, UserType: string(identity.EndGuest), Amr: "guest_phone", TTL: guestTokenTTL, }) if err != nil { logs.Error("[guest] 签发游客令牌失败: %v", err) c.gReply(500, "签发游客令牌失败", nil) return } c.gReply(200, "success", map[string]interface{}{ "token": token, "guest_id": guest.ID, "phone_mask": maskPhone(mobile), "expires_in": int(guestTokenTTL.Seconds()), }) } // identityByPhone 按微信已验证手机号定位认证中心身份。 // 租户账号手机号可能落在三处,逐层兜底(先查认证中心,查不到再回退业务表): // 1. yz_auth_identity.mobile/account/email(FindIdentityByAccount) // 2. yz_auth_tenant_user.phone → identity_id // 3. yz_system_tenant_user.phone → uid(全量迁移后 uid == identity_id) func identityByPhone(mobile string) *models.AuthIdentity { if ai, err := authsvc.FindIdentityByAccount(mobile); err == nil && ai != nil { return ai } var identityID uint64 var tu models.AuthTenantUser if err := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("phone", mobile).Filter("delete_time__isnull", true). OrderBy("-is_default", "id").One(&tu); err == nil { identityID = tu.IdentityID } else { var su models.SystemTenantUser if err2 := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("phone", mobile).Filter("delete_time__isnull", true). OrderBy("-is_default", "id").One(&su); err2 == nil { identityID = su.Uid } } if identityID == 0 { return nil } ai := &models.AuthIdentity{} if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID).Filter("delete_time__isnull", true).One(ai); err != nil { return nil } return ai } // phoneOfIdentity 取身份手机号:identity.mobile 为空时回退租户绑定/业务用户表。 func phoneOfIdentity(identityID uint64) string { var tus []models.AuthTenantUser if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("identity_id", identityID).Filter("delete_time__isnull", true). OrderBy("-is_default", "id").All(&tus); err == nil { for _, tu := range tus { if tu.Phone != nil { if p := strings.TrimSpace(*tu.Phone); p != "" { return p } } } } var sus []models.SystemTenantUser if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("uid", identityID).Filter("delete_time__isnull", true). OrderBy("-is_default", "id").All(&sus); err == nil { for _, su := range sus { if su.Phone != nil { if p := strings.TrimSpace(*su.Phone); p != "" { return p } } } } return "" } // guestMiniSession 拿 wx.login code 换 openid/unionid;失败不阻断(返回 nil)。 func guestMiniSession(cfg *wechatmini.Config, code string) *wechatmini.Session { if strings.TrimSpace(code) == "" { return nil } session, err := wechatmini.Code2Session(cfg, code) if err != nil { logs.Warn("[guest] code 换取 openid 失败: %v", err) return nil } return session } // identityByWechat 按 openid/unionid 查已绑定的正式身份(登录页静默 bind 的落点)。 // 与小程序一键登录 resolveIdentity 同源:手机号查不到时的第二级认人方式。 func identityByWechat(openID, unionID string) *models.AuthIdentity { var bind models.AuthIdentityThird qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", "wechat_mini") found := false if openID != "" { if err := qs.Filter("open_id", openID).One(&bind); err == nil { found = true } } if !found && unionID != "" { if err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("provider", "wechat_mini").Filter("union_id", unionID).One(&bind); err == nil { found = true } } if !found { return nil } ai := &models.AuthIdentity{} if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", bind.IdentityID).Filter("delete_time__isnull", true).One(ai); err != nil { return nil } return ai } // bindWechatMini 把当前小程序 openid 绑到该身份。手机号已经微信验证, // 属于强证据,允许覆盖旧绑定(与 controllers/auth syncBinding 的 verified 分支一致)。 func bindWechatMini(session *wechatmini.Session, identityID uint64) { var bind models.AuthIdentityThird qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", "wechat_mini") if err := qs.Filter("open_id", session.OpenID).One(&bind); err == nil { if bind.IdentityID == identityID { return } _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("id", bind.ID).Delete() } item := &models.AuthIdentityThird{IdentityID: identityID, Provider: "wechat_mini", OpenID: session.OpenID} if session.UnionID != "" { uid := session.UnionID item.UnionID = &uid } _, _ = models.Orm.Insert(item) } // tryUserLogin 一键授权时的正式身份识别:手机号命中启用账号且该账号有企业, // 则签发正式令牌并返回 type=user(对齐小程序一键登录:单企业直接进入, // 多企业返回待选令牌由企业选择接口完成切换)。已处理返回 true。 // 未命中 / 账号无企业 / 签发失败 → 返回 false,回落到游客签发流程。 func (c *GuestController) tryUserLogin(mobile string, session *wechatmini.Session) bool { var authIdentity *models.AuthIdentity if session != nil { authIdentity = identityByWechat(session.OpenID, session.UnionID) } if authIdentity == nil { authIdentity = identityByPhone(mobile) } if authIdentity == nil { return false } // 手机号已通过微信验证:把 openid 补绑/改绑到该身份(下次无手机号也能认人) if session != nil && session.OpenID != "" { bindWechatMini(session, authIdentity.ID) } if authIdentity.Status != models.AuthIdentityStatusEnabled { c.gReply(403, "账号已被禁用或锁定,无法使用", nil) return true } tenants, lerr := authsvc.ListTenantOptions(authIdentity.ID) if lerr != nil || len(tenants) == 0 { return false // 账号未加入任何企业:按游客模式继续使用 } needChoose := len(tenants) > 1 tid := tenants[0].Tid accessTTL := 0 if needChoose { tid = authsvc.PendingTenantID accessTTL = 600 // 待选状态令牌只够用户完成企业选择 } username := "微信用户" if authIdentity.Nickname != nil && strings.TrimSpace(*authIdentity.Nickname) != "" { username = strings.TrimSpace(*authIdentity.Nickname) } else if authIdentity.Mobile != nil && *authIdentity.Mobile != "" { username = *authIdentity.Mobile } sess, serr := authsvc.CreateSession(authsvc.SessionInfo{ IdentityID: authIdentity.ID, Tid: tid, ClientID: "yz-miniapp", IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), LoginType: "wechat_mini", Amr: "wechat_mini", }) if serr != nil { logs.Error("[guest] 正式登录创建会话失败: %v", serr) return false } tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{ IdentityID: authIdentity.ID, Tid: tid, ClientID: "yz-miniapp", Sid: sess.Sid, Username: username, UserType: "app", Amr: "wechat_mini", AccessTTL: accessTTL, }) if terr != nil { logs.Error("[guest] 正式登录签发令牌失败: %v", terr) return false } identityIDCopy := authIdentity.ID tidCopy := tid amr := "wechat_mini" _, _ = models.Orm.Insert(&models.AuthLoginLog{ Tid: &tidCopy, IdentityID: &identityIDCopy, UserName: username, ClientID: "yz-miniapp", LoginType: "wechat_mini", Amr: &amr, Status: 1, Message: "小程序手机号一键登录成功(免登录入口识别为正式账号)", IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), }) c.gReply(200, "success", map[string]interface{}{ "type": "user", "tokens": tokens, "tenants": tenants, "need_choose_tenant": needChoose, }) return true } // findGuestForIdentity 找回当前身份对应的未合并游客记录: // 优先按手机号,查不到再按该身份已绑定的微信 openid/unionid // (微信验证号可能与账号注册号不一致,openid 是另一条可靠对应关系)。 func findGuestForIdentity(identityID uint64, mobile string) (models.GuestUser, bool) { var guest models.GuestUser if mobile != "" { err := models.Orm.QueryTable(new(models.GuestUser)). Filter("phone", mobile).Filter("merged_uid", 0).One(&guest) if err == nil { return guest, true } } var binds []models.AuthIdentityThird if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", identityID).Filter("provider", "wechat_mini").All(&binds); err == nil { for _, b := range binds { if b.OpenID != "" { if err := models.Orm.QueryTable(new(models.GuestUser)). Filter("openid", b.OpenID).Filter("merged_uid", 0).One(&guest); err == nil { return guest, true } } if b.UnionID != nil && *b.UnionID != "" { if err := models.Orm.QueryTable(new(models.GuestUser)). Filter("unionid", *b.UnionID).Filter("merged_uid", 0).One(&guest); err == nil { return guest, true } } } } return guest, false } // Merge POST /api/guest/merge(携带正式令牌) // 按当前身份手机号找回游客记录,把 yz_guest_data 各模块数据并入正式账号。 func (c *GuestController) Merge() { claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) if err != nil || claims.UserID <= 0 { c.gReply(401, "未登录或无权限", nil) return } var ai models.AuthIdentity if err = models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", claims.UserID).One(&ai); err != nil { if err == orm.ErrNoRows { c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false}) return } c.gReply(500, "查询身份失败", nil) return } // 回查手机号用于定位游客记录:identity.mobile 为空时回退租户绑定/业务用户表 var mobile string if ai.Mobile != nil { mobile = strings.TrimSpace(*ai.Mobile) } if mobile == "" { mobile = phoneOfIdentity(ai.ID) } guest, found := findGuestForIdentity(ai.ID, mobile) if !found { c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false}) return } counts, merr := mergeGuestToUser(&guest, uint64(claims.UserID), claims.TenantId, claims.Username) if merr != nil { logs.Error("[guest] 合并失败 guest=%d: %v", guest.ID, merr) c.gReply(500, "合并游客数据失败", nil) return } now := time.Now() _, err = models.Orm.QueryTable(new(models.GuestUser)).Filter("id", guest.ID).Update(map[string]interface{}{ "merged_uid": uint64(claims.UserID), "merged_time": now, }) if err != nil { logs.Error("[guest] 标记合并状态失败 guest=%d: %v", guest.ID, err) } c.gReply(200, "success", map[string]interface{}{"merged": true, "counts": counts}) } // ---------- 通用游客数据接口(供后续免登录模块直接复用) ---------- func (c *GuestController) guestClaims() (*jwtutil.Claims, error) { return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndGuest) } // DataList GET /api/guest/data?module=xxx func (c *GuestController) DataList() { claims, err := c.guestClaims() if err != nil || claims.UserID <= 0 { c.gReply(identity.Status(err), "游客令牌无效", nil) return } module := strings.TrimSpace(c.GetString("module")) if module == "" { c.gReply(400, "缺少 module 参数", nil) return } rows, err := gueststore.List(uint64(claims.UserID), module) if err != nil { c.gReply(500, "查询失败", nil) return } list := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { list = append(list, guestRowToMap(r)) } c.gReply(200, "success", map[string]interface{}{"list": list, "total": len(list)}) } // DataCreate POST /api/guest/data body: { "module": "xxx", "payload": {...} } func (c *GuestController) DataCreate() { claims, err := c.guestClaims() if err != nil || claims.UserID <= 0 { c.gReply(identity.Status(err), "游客令牌无效", nil) return } var req struct { Module string `json:"module"` Payload json.RawMessage `json:"payload"` } if e := c.bindJSON(&req); e != nil { c.gReply(400, "参数错误", nil) return } req.Module = strings.TrimSpace(req.Module) if req.Module == "" || len(req.Payload) == 0 { c.gReply(400, "module 与 payload 不能为空", nil) return } id, err := gueststore.Insert(uint64(claims.UserID), req.Module, req.Payload) if err != nil { c.gReply(500, "保存失败", nil) return } c.gReply(200, "success", map[string]interface{}{"id": id}) } // DataUpdate PUT /api/guest/data/:id body: { "module": "xxx", "payload": {...} } func (c *GuestController) DataUpdate() { claims, err := c.guestClaims() if err != nil || claims.UserID <= 0 { c.gReply(identity.Status(err), "游客令牌无效", nil) return } id := c.parseIDParam() if id == 0 { c.gReply(400, "无效ID", nil) return } var req struct { Module string `json:"module"` Payload json.RawMessage `json:"payload"` } if e := c.bindJSON(&req); e != nil { c.gReply(400, "参数错误", nil) return } if strings.TrimSpace(req.Module) == "" || len(req.Payload) == 0 { c.gReply(400, "module 与 payload 不能为空", nil) return } if err = gueststore.Update(uint64(claims.UserID), req.Module, id, req.Payload); err != nil { if err == gueststore.ErrNotFound { c.gReply(404, "记录不存在", nil) return } c.gReply(500, "更新失败", nil) return } c.gReply(200, "success", nil) } // DataDelete DELETE /api/guest/data/:id?module=xxx func (c *GuestController) DataDelete() { claims, err := c.guestClaims() if err != nil || claims.UserID <= 0 { c.gReply(identity.Status(err), "游客令牌无效", nil) return } id := c.parseIDParam() module := strings.TrimSpace(c.GetString("module")) if id == 0 || module == "" { c.gReply(400, "无效ID或缺少 module", nil) return } if err = gueststore.Delete(uint64(claims.UserID), module, id); err != nil { if err == gueststore.ErrNotFound { c.gReply(404, "记录不存在", nil) return } c.gReply(500, "删除失败", nil) return } c.gReply(200, "success", nil) } // ---------- 小工具 ---------- func (c *GuestController) bindJSON(v interface{}) error { body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } return json.Unmarshal(body, v) } func (c *GuestController) parseIDParam() uint64 { idStr := c.Ctx.Input.Param(":id") var id uint64 for _, ch := range idStr { if ch < '0' || ch > '9' { return 0 } id = id*10 + uint64(ch-'0') } return id } // guestRowToMap 把存储行转成前端条目:payload 展开 + 以表 ID 覆盖 id + 时间戳。 func guestRowToMap(r gueststore.Row) map[string]interface{} { item := map[string]interface{}{} if len(r.Payload) > 0 { _ = json.Unmarshal(r.Payload, &item) } item["id"] = r.ID item["created_at"] = r.CreateTime.Format("2006-01-02 15:04:05") if r.UpdateTime != nil { item["updated_at"] = r.UpdateTime.Format("2006-01-02 15:04:05") } else { item["updated_at"] = item["created_at"] } return item }