package main // profile.go 用户公开资料:昵称/头衔/邮箱/简介/技术栈标签 + 头像缩略图。 // 本地存 meta(离线可编辑),登录同步时按 LWW 与远端 /profile 推拉。 import ( "encoding/base64" "encoding/json" "errors" "net/http" "os" "strconv" "strings" ) type UserProfile struct { Nickname string `json:"nickname"` Title string `json:"title"` Email string `json:"email"` Bio string `json:"bio"` TechTags []string `json:"techTags"` UpdatedAt string `json:"updatedAt"` } func parseTechTags(s string) []string { out := []string{} if e := json.Unmarshal([]byte(s), &out); e != nil { return []string{} } return out } func sanitizeProfile(p *UserProfile) error { trim := func(s string, max int) string { s = strings.TrimSpace(s) if r := []rune(s); len(r) > max { return string(r[:max]) } return s } p.Nickname = trim(p.Nickname, 32) p.Title = trim(p.Title, 48) p.Email = trim(p.Email, 128) p.Bio = trim(p.Bio, 300) if p.Email != "" && (!strings.Contains(p.Email, "@") || strings.ContainsAny(p.Email, " \t")) { return errors.New("PROFILE_EMAIL_INVALID") } tags, seen := []string{}, map[string]bool{} for _, t := range p.TechTags { t = trim(t, 24) if t == "" || seen[strings.ToLower(t)] { continue } seen[strings.ToLower(t)] = true tags = append(tags, t) if len(tags) >= 20 { break } } p.TechTags = tags return nil } func (a *App) myProfileLocal() UserProfile { p := UserProfile{TechTags: []string{}} raw := a.store.Meta("user_profile") if raw != "" { _ = json.Unmarshal([]byte(raw), &p) } if p.TechTags == nil { p.TechTags = []string{} } return p } func (a *App) GetMyProfile() (UserProfile, error) { if e := a.ready(); e != nil { return UserProfile{}, e } return a.myProfileLocal(), nil } // SaveMyProfile 保存资料到本地并异步推送(登录状态下)。 func (a *App) SaveMyProfile(p UserProfile) (UserProfile, error) { if e := a.ready(); e != nil { return UserProfile{}, e } if e := sanitizeProfile(&p); e != nil { return UserProfile{}, e } p.UpdatedAt = nowRFC() raw, _ := json.Marshal(p) if e := a.store.SetMeta("user_profile", string(raw)); e != nil { return UserProfile{}, e } if a.syncUserID() > 0 { go a.syncOnce(true) } return p, nil } // avatarThumb 生成成员列表用的小头像:base64/path 源缩到 64px,url 源原样返回。 func (a *App) avatarThumb() string { st, e := a.store.Settings() if e != nil { return "" } switch st.AvatarMode { case "url": return st.AvatarValue case "base64": raw, e := dataURLBytes(st.AvatarValue) if e != nil { return "" } return encodeThumb(raw) case "path": raw, e := os.ReadFile(st.AvatarValue) if e != nil || len(raw) > avatarMaxFileBytes { return "" } return encodeThumb(raw) } return "" } func encodeThumb(raw []byte) string { data, mime, e := encodeImageEdge(raw, 64) if e != nil { return "" } return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) } // syncUserProfile 在同步循环中推拉公开资料(LWW),并保持头像缩略图最新。 func (a *App) syncUserProfile() { local := a.myProfileLocal() var remote struct { Nickname string `json:"nickname"` Title string `json:"title"` Email string `json:"email"` Bio string `json:"bio"` TechTags []string `json:"techTags"` AvatarThumb string `json:"avatarThumb"` UpdatedAt string `json:"updatedAt"` } if e := a.apiDecode(http.MethodGet, "/api/v1/profile", nil, &remote, true); e != nil { return } hasRemote := remote.UpdatedAt != "" switch { case hasRemote && remote.UpdatedAt > local.UpdatedAt: p := UserProfile{ Nickname: remote.Nickname, Title: remote.Title, Email: remote.Email, Bio: remote.Bio, TechTags: remote.TechTags, UpdatedAt: remote.UpdatedAt, } if p.TechTags == nil { p.TechTags = []string{} } raw, _ := json.Marshal(p) _ = a.store.SetMeta("user_profile", string(raw)) case local.UpdatedAt != "" && (!hasRemote || local.UpdatedAt > remote.UpdatedAt): body := map[string]any{ "nickname": local.Nickname, "title": local.Title, "email": local.Email, "bio": local.Bio, "techTags": local.TechTags, "avatarThumb": a.avatarThumb(), "updatedAt": local.UpdatedAt, } _ = a.apiDecode(http.MethodPut, "/api/v1/profile", body, nil, true) return } // 头像单独变更:只刷新缩略图(服务端 Put 会带上 avatarThumb)。 avAt := a.store.Meta("avatar_updated_at") if avAt != "" && avAt != a.store.Meta("profile_avatar_pushed_at") { body := map[string]any{ "nickname": local.Nickname, "title": local.Title, "email": local.Email, "bio": local.Bio, "techTags": local.TechTags, "avatarThumb": a.avatarThumb(), "updatedAt": local.UpdatedAt, } if local.UpdatedAt == "" { body["updatedAt"] = nowRFC() } if a.apiDecode(http.MethodPut, "/api/v1/profile", body, nil, true) == nil { _ = a.store.SetMeta("profile_avatar_pushed_at", avAt) } } } // AvatarHistoryItem 线上头像历史一条。 type AvatarHistoryItem struct { ID int64 `json:"id"` Mode string `json:"mode"` Value string `json:"value"` CreatedAt string `json:"createdAt"` } func (a *App) avatarHistoryLoggedIn() bool { return a.syncUserID() > 0 && strings.TrimSpace(a.store.Meta("sync_access_token")) != "" } // ListAvatarHistory 拉取当前账号的线上头像历史(需已登录)。 func (a *App) ListAvatarHistory() ([]AvatarHistoryItem, error) { if e := a.ready(); e != nil { return nil, e } if !a.avatarHistoryLoggedIn() { return nil, errors.New("SYNC_NOT_LOGGED_IN") } var resp struct { Items []AvatarHistoryItem `json:"items"` } if e := a.apiDecode(http.MethodGet, "/api/v1/profile/avatars", nil, &resp, true); e != nil { return nil, e } if resp.Items == nil { resp.Items = []AvatarHistoryItem{} } return resp.Items, nil } // PushAvatarHistory 把一条头像写入线上历史(path 模式会被服务端忽略)。 func (a *App) PushAvatarHistory(mode, value string) ([]AvatarHistoryItem, error) { if e := a.ready(); e != nil { return nil, e } if !a.avatarHistoryLoggedIn() { return nil, errors.New("SYNC_NOT_LOGGED_IN") } value = strings.TrimSpace(value) if value == "" { return nil, errors.New("AVATAR_VALUE_REQUIRED") } var resp struct { Items []AvatarHistoryItem `json:"items"` } body := map[string]string{"mode": mode, "value": value} if e := a.apiDecode(http.MethodPost, "/api/v1/profile/avatars", body, &resp, true); e != nil { return nil, e } if resp.Items == nil { resp.Items = []AvatarHistoryItem{} } return resp.Items, nil } // DeleteAvatarHistory 删除线上一条头像历史。 func (a *App) DeleteAvatarHistory(id int64) error { if e := a.ready(); e != nil { return e } if !a.avatarHistoryLoggedIn() { return errors.New("SYNC_NOT_LOGGED_IN") } return a.apiDecode(http.MethodDelete, "/api/v1/profile/avatars/"+strconv.FormatInt(id, 10), nil, nil, true) }