From 012f8edf8bfd458394d9aef37d780f9e1cb806f9 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Tue, 3 Nov 2026 11:13:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/controllers/guest.go | 355 +++++++++++++++++++++++++++ go/controllers/guest_merge.go | 204 +++++++++++++++ go/models/guest.go | 38 +++ go/services/gueststore/gueststore.go | 181 ++++++++++++++ 4 files changed, 778 insertions(+) create mode 100644 go/controllers/guest.go create mode 100644 go/controllers/guest_merge.go create mode 100644 go/models/guest.go create mode 100644 go/services/gueststore/gueststore.go diff --git a/go/controllers/guest.go b/go/controllers/guest.go new file mode 100644 index 0000000..8e987ee --- /dev/null +++ b/go/controllers/guest.go @@ -0,0 +1,355 @@ +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 +} diff --git a/go/controllers/guest_merge.go b/go/controllers/guest_merge.go new file mode 100644 index 0000000..386879a --- /dev/null +++ b/go/controllers/guest_merge.go @@ -0,0 +1,204 @@ +package controllers + +// ==================== 游客数据转正合并 ==================== +// +// 正式登录后按手机号把 yz_guest_data 里的条目迁入各模块业务表。 +// 新免登录模块接入时:在 guestMergeAdapters 注册一个适配器即可, +// rows 为该模块全部有效游客条目,返回成功迁移的条数。 + +import ( + "encoding/json" + "strings" + "time" + + "server/models" + "server/services/gueststore" + + "github.com/beego/beego/v2/core/logs" +) + +// 游客数据所属模块编码(与 gueststore 的 module 字段一致) +const ( + guestModuleNotebook = "notebook" + guestModuleSchedule = "schedule" + guestModulePasswordStore = "passwordStore" +) + +// guestNoteItem 游客记事本条目:与 /api/notebook 登录态响应字段一致, +// 前端拿到的形状不因游客/登录而变化。 +type guestNoteItem struct { + ID uint64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Pinned bool `json:"pinned"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// guestScheduleItem 游客日程条目:与 /api/schedule 登录态响应字段一致。 +// 游客日程不接提醒发送链路(无短信/邮件推送),仅作待办记录。 +type guestScheduleItem struct { + ID uint64 `json:"id"` + Content string `json:"content"` + ScheduleTime string `json:"schedule_time"` + RemindChannels []string `json:"remind_channels"` + AdvanceMinutes int `json:"advance_minutes"` + RepeatIntervalMinutes int `json:"repeat_interval_minutes"` + MaxSendCount int `json:"max_send_count"` + IsFinished bool `json:"is_finished"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// mergeGuestToUser 把游客的全部模块数据并入正式身份(AuthIdentity.ID=uid, 租户 tid)。 +// 单条失败只记日志不中断;完成后游客数据行统一置 is_deleted=2(保留行本身)。 +func mergeGuestToUser(guest *models.GuestUser, uid uint64, tid int, username string) (map[string]int, error) { + counts := map[string]int{} + + // ---- 记事本 ---- + if rows, err := gueststore.List(guest.ID, guestModuleNotebook); err != nil { + logs.Error("[guest-merge] list notebook failed guest=%d: %v", guest.ID, err) + } else { + for _, r := range rows { + var item guestNoteItem + if json.Unmarshal(r.Payload, &item) != nil { + continue + } + userID, name := uid, username + pinned := int8(0) + if item.Pinned { + pinned = 1 + } + if _, err := models.Orm.Insert(&models.BackendNotebook{ + Tid: tid, + Title: item.Title, + Content: item.Content, + Pinned: pinned, + UserID: &userID, + UserName: &name, + }); err != nil { + logs.Error("[guest-merge] insert notebook row=%d: %v", r.ID, err) + continue + } + counts[guestModuleNotebook]++ + } + } + + // ---- 日程 ---- + if rows, err := gueststore.List(guest.ID, guestModuleSchedule); err != nil { + logs.Error("[guest-merge] list schedule failed guest=%d: %v", guest.ID, err) + } else { + for _, r := range rows { + var item guestScheduleItem + if json.Unmarshal(r.Payload, &item) != nil { + continue + } + schedTime, terr := time.ParseInLocation("2006-01-02 15:04:05", item.ScheduleTime, time.Local) + if terr != nil { + logs.Warn("[guest-merge] schedule row=%d 时间非法,跳过", r.ID) + continue + } + title := firstLineOf(item.Content, "日程提醒") + schedule := models.BackendSchedule{ + Tid: tid, + Title: title, + Content: item.Content, + ScheduleTime: schedTime, + UserID: uid, + } + schedID, ierr := models.Orm.Insert(&schedule) + if ierr != nil { + logs.Error("[guest-merge] insert schedule row=%d: %v", r.ID, ierr) + continue + } + // 提醒行按登录态创建逻辑重建;已完成的不再发送 + status := int8(0) + if item.IsFinished { + status = 2 + } + for _, ch := range normalizeRemindChannels(item.RemindChannels) { + reminder := models.BackendScheduleReminder{ + Tid: tid, + ScheduleID: uint64(schedID), + RemindChannel: ch, + AdvanceMinutes: item.AdvanceMinutes, + NextRemindTime: schedTime.Add(-time.Duration(item.AdvanceMinutes) * time.Minute), + ReceiverUserID: uid, + RemindStatus: status, + RepeatIntervalMinutes: item.RepeatIntervalMinutes, + MaxSendCount: item.MaxSendCount, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + if _, rerr := models.Orm.Insert(&reminder); rerr != nil { + logs.Error("[guest-merge] insert reminder row=%d ch=%s: %v", r.ID, ch, rerr) + } + } + counts[guestModuleSchedule]++ + } + } + + // ---- 密码库 ---- + if rows, err := gueststore.List(guest.ID, guestModulePasswordStore); err != nil { + logs.Error("[guest-merge] list passwordStore failed guest=%d: %v", guest.ID, err) + } else { + for _, r := range rows { + var item PasswordStoreItemDTO + if json.Unmarshal(r.Payload, &item) != nil { + continue + } + if item.Platform == "" { + continue + } + if _, err := models.Orm.Insert(&models.BackendPasswordStore{ + Tid: tid, + UserID: uid, + Platform: item.Platform, + URL: item.URL, + Accounts: accountsToJSON(item.Accounts), + Remark: item.Remark, + }); err != nil { + logs.Error("[guest-merge] insert passwordStore row=%d: %v", r.ID, err) + continue + } + counts[guestModulePasswordStore]++ + } + } + + if err := gueststore.MarkMerged(guest.ID); err != nil { + return counts, err + } + return counts, nil +} + +// firstLineOf 取首行作为标题(与登录态日程创建逻辑一致)。 +func firstLineOf(content, fallback string) string { + title := strings.SplitN(content, "\n", 2)[0] + if len(title) > 60 { + title = title[:60] + } + if title == "" { + title = fallback + } + return title +} + +// normalizeRemindChannels 与 AppReminderController.Create 相同的通道白名单: +// APP 归一为 SITE_MSG,非法值丢弃。 +func normalizeRemindChannels(list []string) []string { + out := make([]string, 0, len(list)) + for _, ch := range list { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch == "APP" { + ch = "SITE_MSG" + } + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" { + continue + } + out = append(out, ch) + } + return out +} diff --git a/go/models/guest.go b/go/models/guest.go new file mode 100644 index 0000000..59d0cd2 --- /dev/null +++ b/go/models/guest.go @@ -0,0 +1,38 @@ +package models + +import "time" + +// ==================== 游客(免登录模块)轻身份与通用数据 ==================== +// +// 设计:免登录模块「不登录也要能存数据」。小程序用手机号授权换取游客轻身份 +// (yz_guest_user),业务条目按 guest_id + module 文档式存入 yz_guest_data, +// 永久保留不清理;正式登录后按手机号把数据合并进正式账号(见 controllers/guest_merge.go)。 + +// GuestUser 游客轻身份,以微信验证手机号为唯一标识。 +type GuestUser struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Phone string `orm:"column(phone);size(20)" json:"phone"` + OpenID string `orm:"column(openid);size(64);default('')" json:"openid"` + UnionID string `orm:"column(unionid);size(64);default('')" json:"unionid"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + LastActiveTime *time.Time `orm:"column(last_active_time);type(datetime);null" json:"last_active_time"` + MergedUID uint64 `orm:"column(merged_uid);default(0)" json:"merged_uid"` + MergedTime *time.Time `orm:"column(merged_time);type(datetime);null" json:"merged_time"` +} + +func (*GuestUser) TableName() string { return "yz_guest_user" } + +// GuestData 游客通用数据行:一条业务条目 = 一行 JSON 文档。 +// module 取值与免登录业务对应(notebook / schedule / passwordStore / …), +// 新模块直接复用本表,无需再加业务表。 +type GuestData struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + GuestID uint64 `orm:"column(guest_id)" json:"guest_id"` + Module string `orm:"column(module);size(50)" json:"module"` + Payload string `orm:"column(payload);type(text)" json:"payload"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` // 0正常 1已删除 2已合并 + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (*GuestData) TableName() string { return "yz_guest_data" } diff --git a/go/services/gueststore/gueststore.go b/go/services/gueststore/gueststore.go new file mode 100644 index 0000000..e0352ea --- /dev/null +++ b/go/services/gueststore/gueststore.go @@ -0,0 +1,181 @@ +// Package gueststore 游客(免登录模块)通用存储。 +// +// 一条业务条目 = yz_guest_data 一行 JSON 文档,按 guest_id + module 隔离; +// 删除只做软删(is_deleted=1)永久保留,合并进正式账号后置 is_deleted=2。 +// 业务控制器通过 identity.IsGuest 分流到这里,响应结构与登录态接口保持一致。 +package gueststore + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" +) + +// ErrNotFound 条目不存在(或不属于该游客 / 已被删除)。 +var ErrNotFound = errors.New("记录不存在") + +// Row 一条游客数据文档(对外统一用表 ID 作为条目 ID,payload 内的 id 仅冗余)。 +type Row struct { + ID uint64 + Payload json.RawMessage + CreateTime time.Time + UpdateTime *time.Time +} + +// Upsert 按手机号定位/创建游客身份,并刷新 openid / 活跃时间。 +// 已合并(merged_uid>0)的旧记录会被复位为新的游客周期: +// 历史数据行在合并时已置 is_deleted=2,不会再次出现在列表里。 +func Upsert(phone, openid, unionid string) (*models.GuestUser, error) { + phone = strings.TrimSpace(phone) + if phone == "" { + return nil, errors.New("手机号不能为空") + } + now := time.Now() + + var row models.GuestUser + err := models.Orm.QueryTable(new(models.GuestUser)).Filter("phone", phone).One(&row) + if err == orm.ErrNoRows { + row = models.GuestUser{Phone: phone, OpenID: openid, UnionID: unionid, LastActiveTime: &now} + id, ierr := models.Orm.Insert(&row) + if ierr != nil { + return nil, ierr + } + row.ID = uint64(id) + return &row, nil + } + if err != nil { + return nil, err + } + + upd := map[string]interface{}{"last_active_time": now} + if openid != "" { + upd["openid"] = openid + } + if unionid != "" { + upd["unionid"] = unionid + } + if row.MergedUID > 0 { + // 复位游客周期,允许同一手机号再次以游客身份产生新数据 + upd["merged_uid"] = uint64(0) + upd["merged_time"] = nil + row.MergedUID = 0 + } + if _, err = models.Orm.QueryTable(new(models.GuestUser)).Filter("id", row.ID).Update(upd); err != nil { + return nil, err + } + row.LastActiveTime = &now + return &row, nil +} + +// Insert 写入一条业务条目,返回行 ID(即条目 ID)。 +func Insert(guestID uint64, module string, payload interface{}) (uint64, error) { + b, err := json.Marshal(payload) + if err != nil { + return 0, err + } + now := time.Now() + id, err := models.Orm.Insert(&models.GuestData{ + GuestID: guestID, + Module: module, + Payload: string(b), + CreateTime: now, + UpdateTime: &now, + }) + if err != nil { + return 0, err + } + return uint64(id), nil +} + +// Get 读取单条(限本游客 + 本模块 + 未删除)。 +func Get(guestID uint64, module string, id uint64) (Row, error) { + var d models.GuestData + err := models.Orm.QueryTable(new(models.GuestData)). + Filter("id", id). + Filter("guest_id", guestID). + Filter("module", module). + Filter("is_deleted", 0). + One(&d) + if err == orm.ErrNoRows { + return Row{}, ErrNotFound + } + if err != nil { + return Row{}, err + } + return toRow(d), nil +} + +// List 列出某游客某模块的全部有效条目(新→旧)。 +func List(guestID uint64, module string) ([]Row, error) { + var rows []models.GuestData + _, err := models.Orm.QueryTable(new(models.GuestData)). + Filter("guest_id", guestID). + Filter("module", module). + Filter("is_deleted", 0). + OrderBy("-id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + return nil, err + } + out := make([]Row, 0, len(rows)) + for _, d := range rows { + out = append(out, toRow(d)) + } + return out, nil +} + +// Update 覆盖一条条目的 payload。 +func Update(guestID uint64, module string, id uint64, payload interface{}) error { + b, err := json.Marshal(payload) + if err != nil { + return err + } + n, err := models.Orm.QueryTable(new(models.GuestData)). + Filter("id", id). + Filter("guest_id", guestID). + Filter("module", module). + Filter("is_deleted", 0). + Update(map[string]interface{}{"payload": string(b), "update_time": time.Now()}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// Delete 软删除一条条目(保留数据行,永久不清理)。 +func Delete(guestID uint64, module string, id uint64) error { + n, err := models.Orm.QueryTable(new(models.GuestData)). + Filter("id", id). + Filter("guest_id", guestID). + Filter("module", module). + Filter("is_deleted", 0). + Update(map[string]interface{}{"is_deleted": 1, "update_time": time.Now()}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// MarkMerged 游客转正后:该游客全部有效数据行置 is_deleted=2(保留行本身)。 +func MarkMerged(guestID uint64) error { + _, err := models.Orm.QueryTable(new(models.GuestData)). + Filter("guest_id", guestID). + Filter("is_deleted", 0). + Update(map[string]interface{}{"is_deleted": 2, "update_time": time.Now()}) + return err +} + +func toRow(d models.GuestData) Row { + return Row{ID: d.ID, Payload: json.RawMessage(d.Payload), CreateTime: d.CreateTime, UpdateTime: d.UpdateTime} +}