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" "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 仅作溯源,拿不到不阻断游客签发 var openID, unionID string if req.Code != "" { if session, serr := wechatmini.Code2Session(cfg, req.Code); serr == nil && 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 } 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()), }) } // 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 } if ai.Mobile == nil || strings.TrimSpace(*ai.Mobile) == "" { c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false}) return } var guest models.GuestUser err = models.Orm.QueryTable(new(models.GuestUser)). Filter("phone", strings.TrimSpace(*ai.Mobile)). Filter("merged_uid", 0). One(&guest) if err == orm.ErrNoRows { c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false}) return } if err != nil { c.gReply(500, "查询游客记录失败", nil) 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 }