Files
yunzerwebsiteallinone/go/controllers/guest.go
T
2026-11-11 16:58:30 +08:00

610 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}