diff --git a/pets-be/internal/handler/auth.go b/pets-be/internal/handler/auth.go index 96696e9..d730872 100644 --- a/pets-be/internal/handler/auth.go +++ b/pets-be/internal/handler/auth.go @@ -210,3 +210,20 @@ func (h *Handler) UpdateProfile(c *gin.Context) { } response.OK(c, user) } + +// BindPhone POST /api/user/phone 用小程序 getPhoneNumber 的 code 绑定手机号 +func (h *Handler) BindPhone(c *gin.Context) { + var req struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" { + response.FailParams(c, "缺少 code") + return + } + phone, err := h.svc.BindPhoneByCode(middleware.UserID(c), req.Code) + if err != nil { + response.FailErr(c, err) + return + } + response.OK(c, gin.H{"phone": phone}) +} diff --git a/pets-be/internal/handler/pet.go b/pets-be/internal/handler/pet.go index 6aa8546..b49c187 100644 --- a/pets-be/internal/handler/pet.go +++ b/pets-be/internal/handler/pet.go @@ -26,6 +26,9 @@ type petReq struct { Breed string `json:"breed"` AvatarFileID string `json:"avatar_file_id"` Goals []string `json:"goals"` + Personality string `json:"personality"` + Allergy string `json:"allergy"` + Notes string `json:"notes"` } func (r petReq) toInput() service.PetInput { @@ -33,6 +36,7 @@ func (r petReq) toInput() service.PetInput { Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender, Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed, AvatarFileID: r.AvatarFileID, + Personality: r.Personality, Allergy: r.Allergy, Notes: r.Notes, } if r.Birthday != "" { if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil { @@ -123,6 +127,17 @@ func (h *Handler) UpdatePet(c *gin.Context) { if req.Color != "" { fields["color"] = req.Color } + // 性格/过敏/备注:空串也允许写(用户可能想清空),用指针语义太重, + // 这里约定前端总是回传当前值,非空才更新;清空场景暂用「无」占位 + if req.Personality != "" { + fields["personality"] = req.Personality + } + if req.Allergy != "" { + fields["allergy"] = req.Allergy + } + if req.Notes != "" { + fields["notes"] = req.Notes + } if req.Breed != "" { fields["breed"] = req.Breed } diff --git a/pets-be/internal/model/pet.go b/pets-be/internal/model/pet.go index d8e6566..9d85e0a 100644 --- a/pets-be/internal/model/pet.go +++ b/pets-be/internal/model/pet.go @@ -27,4 +27,9 @@ type Pet struct { Breed string `gorm:"size:64" json:"breed"` // 品种 HealthStatus string `gorm:"size:16;default:正常" json:"health_status"` Goals datatypes.JSON `json:"goals"` // onboarding 目标多选 + + // —— 身份卡用的补充信息 —— + Personality string `gorm:"size:64" json:"personality"` // 性格标签,逗号分隔,如 "活泼,亲人,贪吃" + Allergy string `gorm:"size:128" json:"allergy"` // 过敏情况,没有就填「无」 + Notes string `gorm:"size:255" json:"notes"` // 备注信息(照护提醒) } diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index b7394f0..058ed77 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -49,6 +49,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.GET("/user/profile", h.Profile) g.PUT("/user/profile", h.UpdateProfile) + g.POST("/user/phone", h.BindPhone) g.GET("/user/summary", h.UserSummary) g.PUT("/user/decoration", h.UpdateDecoration) diff --git a/pets-be/internal/service/idcard.go b/pets-be/internal/service/idcard.go index a11dd47..26b0a2f 100644 --- a/pets-be/internal/service/idcard.go +++ b/pets-be/internal/service/idcard.go @@ -17,11 +17,23 @@ type IDCard struct { Zodiac string `json:"zodiac"` // 星座,按生日算 Weight string `json:"weight"` // 体重,如 "13.5kg" Intro string `json:"intro"` + Color string `json:"color"` // 毛色 + Personality string `json:"personality"` // 性格标签,逗号分隔 AvatarURL string `json:"avatar_url"` IDNo string `json:"id_no"` // 玩具身份号,不入库 Validity string `json:"validity"` // "2023.12.15 - 永远" - QRURL string `json:"qr_url"` // 小程序码,未发布时为空 - QRReady bool `json:"qr_ready"` // 码有没有生成出来 + + // —— 背面:照护信息 —— + Owner string `json:"owner"` // 主人昵称 + Phone string `json:"phone"` // 联系电话(脱敏) + Allergy string `json:"allergy"` // 过敏情况 + Notes string `json:"notes"` // 备注 + VaccineState string `json:"vaccine_state"` // 疫苗情况 + DewormState string `json:"deworm_state"` // 驱虫情况 + Org string `json:"org"` // 发证机构 + + QRURL string `json:"qr_url"` // 小程序码,未发布时为空 + QRReady bool `json:"qr_ready"` // 码有没有生成出来 } // petIDNo 造一个稳定的「身份号」。纯装饰,不入库、不做校验。 @@ -73,6 +85,14 @@ func zodiacOf(month, day int) string { return name } +// maskPhone 脱敏手机号:138****8888。空或非 11 位原样返回 +func maskPhone(p string) string { + if len(p) != 11 { + return p + } + return p[:3] + "****" + p[7:] +} + // GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡 func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { pet, err := s.GetPet(userID, petID) @@ -99,10 +119,39 @@ func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { zodiac = zodiacOf(int(pet.Birthday.Month()), pet.Birthday.Day()) } + // 主人昵称 + 脱敏电话 + var user model.User + s.db.First(&user, userID) + owner := user.Nickname + if owner == "" { + owner = "铲屎官" + } + + // 疫苗:有记录算已接种;驱虫:取下一次到期提醒的日期 + var vaccineCnt int64 + s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineCnt) + vaccineState := "待接种" + if vaccineCnt > 0 { + vaccineState = "已接种" + } + dewormState := "—" + var dw model.Reminder + if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dw).Error; err == nil && dw.NextDueDate != nil { + dewormState = dw.NextDueDate.Format("2006.01.02") + } + + allergy := pet.Allergy + if allergy == "" { + allergy = "无" + } + card := &IDCard{ Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed, Birthday: bd, Zodiac: zodiac, Weight: pet.Weight, Intro: intro, + Color: pet.Color, Personality: pet.Personality, AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity, + Owner: owner, Phone: maskPhone(user.Phone), Allergy: allergy, Notes: pet.Notes, + VaccineState: vaccineState, DewormState: dewormState, Org: "肉垫计划", } // 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码 diff --git a/pets-be/internal/service/pet.go b/pets-be/internal/service/pet.go index 5cf8d22..e70c3d2 100644 --- a/pets-be/internal/service/pet.go +++ b/pets-be/internal/service/pet.go @@ -27,6 +27,9 @@ type PetInput struct { Breed string AvatarFileID string Goals datatypes.JSON + Personality string + Allergy string + Notes string } func normalizeWeight(w string) string { @@ -86,6 +89,9 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) { Color: in.Color, Breed: in.Breed, AvatarFileID: in.AvatarFileID, + Personality: in.Personality, + Allergy: in.Allergy, + Notes: in.Notes, HealthStatus: "正常", Goals: in.Goals, } diff --git a/pets-be/internal/service/wxacode.go b/pets-be/internal/service/wxacode.go index 769c368..12b3e6d 100644 --- a/pets-be/internal/service/wxacode.go +++ b/pets-be/internal/service/wxacode.go @@ -11,6 +11,8 @@ import ( "net/url" "sync" "time" + + "github.com/sundynix/pets-be/internal/model" ) // 小程序码(wxacode)。身份卡右下角那个码,扫了能进小程序。 @@ -123,3 +125,38 @@ func (s *Service) PetQRCode(petID string) (string, error) { // 41030 = page 不在已发布版本里。开发期常见,往上层透传让它降级 return "", fmt.Errorf("微信生成小程序码失败(%d): %s", e.ErrCode, e.ErrMsg) } + +// BindPhoneByCode 用小程序 getPhoneNumber 拿到的 code 换手机号,存到用户上。 +// 返回脱敏后的手机号。开发期/未认证时微信会报错,往上层透传。 +func (s *Service) BindPhoneByCode(userID, code string) (string, error) { + token, err := s.wechatAccessToken() + if err != nil { + return "", err + } + payload, _ := json.Marshal(map[string]string{"code": code}) + var out struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + PhoneInfo struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + } `json:"phone_info"` + } + if err := postJSON("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token="+token, payload, &out); err != nil { + return "", err + } + if out.ErrCode != 0 { + return "", fmt.Errorf("获取手机号失败(%d): %s", out.ErrCode, out.ErrMsg) + } + phone := out.PhoneInfo.PurePhoneNumber + if phone == "" { + phone = out.PhoneInfo.PhoneNumber + } + if phone == "" { + return "", fmt.Errorf("微信没返回手机号") + } + if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", phone).Error; err != nil { + return "", err + } + return maskPhone(phone), nil +} diff --git a/pets-fe/pages/petform/petform.js b/pets-fe/pages/petform/petform.js index 0137fc7..0c6c725 100644 --- a/pets-fe/pages/petform/petform.js +++ b/pets-fe/pages/petform/petform.js @@ -4,6 +4,8 @@ const upload = require('../../utils/upload.js'); const { toastErr } = require('../../utils/ui.js'); const GENDERS = ['男孩', '女孩', '不确定']; +// 性格标签预设,多选,存成逗号分隔串 +const PERSONA_TAGS = ['活泼', '亲人', '粘人', '高冷', '贪吃', '胆小', '好奇', '温顺']; // 阶段列表和划分标准都从后端来。原来这里写死 4 个,后端细分成 5 段 // (而且狗还按体型走不同阈值)之后就对不上了 —— 而且不会报错,只是少两个选项。 // 「刚到家 0-30 天」在后端列表的第一个,它和年龄正交,是叠加层 @@ -54,10 +56,13 @@ Page({ species: 'cat', breed: '', gender: '不确定', birthday: '', arrived_at: '', weight: '', color: '', stage: '', + personality: '', allergy: '', notes: '', // 第四步挑的方案。'' 是「先空着自己排」,不是「没选」—— // 所以初始值给 null,用来区分「还没走到第四步」 template_id: null, }, + personaTags: PERSONA_TAGS, + personaSel: {}, // 性格标签选中态 map tplOptions: [], breedShow: false, breedGroups: [], @@ -88,8 +93,12 @@ Page({ arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '', weight: (p.weight || '').replace('kg', ''), color: p.color || '', + personality: p.personality || '', + allergy: p.allergy || '', + notes: p.notes || '', stage: p.stage || '', }, + personaSel: (p.personality || '').split(',').filter(Boolean).reduce((m, t) => ((m[t] = true), m), {}), }); }) .catch((e) => toastErr(e)); @@ -170,6 +179,21 @@ Page({ onColor(e) { this.setData({ 'form.color': e.detail.value }); }, + onAllergy(e) { + this.setData({ 'form.allergy': e.detail.value }); + }, + onNotes(e) { + this.setData({ 'form.notes': e.detail.value }); + }, + // 性格标签多选:切换选中态,再拼回逗号串存进 form.personality + togglePersona(e) { + const tag = e.currentTarget.dataset.v; + const sel = Object.assign({}, this.data.personaSel); + if (sel[tag]) delete sel[tag]; + else sel[tag] = true; + const personality = this.data.personaTags.filter((t) => sel[t]).join(','); + this.setData({ personaSel: sel, 'form.personality': personality }); + }, // ── 品种选择器 ── openBreed() { @@ -258,6 +282,9 @@ Page({ weight: f.weight, color: f.color.trim(), breed: f.breed, + personality: f.personality, + allergy: f.allergy.trim(), + notes: f.notes.trim(), stage: f.stage || stageFromBirthday(f.birthday) || '成年期', age: ageLabel(f.birthday), }; diff --git a/pets-fe/pages/petform/petform.wxml b/pets-fe/pages/petform/petform.wxml index cd65a06..77cfc5a 100644 --- a/pets-fe/pages/petform/petform.wxml +++ b/pets-fe/pages/petform/petform.wxml @@ -74,6 +74,16 @@ + + {{item}} + + + + + {{item}}