package main // profile.go 用户公开资料:昵称/头衔/邮箱/简介/技术栈标签 + 头像缩略图。 // 本地存 meta(离线可编辑),登录同步时按 LWW 与远端 user_profiles 表推拉, // 供同服务器的团队成员互相查看(表结构见 init.sql)。 import ( "context" "database/sql" "encoding/base64" "encoding/json" "errors" "os" "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"` } // parseTechTags 解析技术栈标签 JSON 数组,坏数据回退空。 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(ctx context.Context, db *sql.DB, userID int64) { local := a.myProfileLocal() var remote UserProfile var remoteTags string e := db.QueryRowContext(ctx, `SELECT nickname,title,email,bio,tech_tags,updated_at FROM user_profiles WHERE user_id=?`, userID). Scan(&remote.Nickname, &remote.Title, &remote.Email, &remote.Bio, &remoteTags, &remote.UpdatedAt) hasRemote := e == nil if e != nil && e != sql.ErrNoRows { return } switch { case hasRemote && remote.UpdatedAt > local.UpdatedAt: remote.TechTags = parseTechTags(remoteTags) raw, _ := json.Marshal(remote) _ = a.store.SetMeta("user_profile", string(raw)) case local.UpdatedAt != "" && (!hasRemote || local.UpdatedAt > remote.UpdatedAt): tags, _ := json.Marshal(local.TechTags) _, _ = db.ExecContext(ctx, `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE nickname=VALUES(nickname),title=VALUES(title),email=VALUES(email),bio=VALUES(bio), tech_tags=VALUES(tech_tags),avatar_thumb=VALUES(avatar_thumb),updated_at=VALUES(updated_at)`, userID, local.Nickname, local.Title, local.Email, local.Bio, string(tags), a.avatarThumb(), local.UpdatedAt) return // 全量推送已带最新头像 } // 头像单独变更:只刷新缩略图列,不动 updated_at(文本字段 LWW 不受影响)。 avAt := a.store.Meta("avatar_updated_at") if avAt != "" && avAt != a.store.Meta("profile_avatar_pushed_at") { if _, e := db.ExecContext(ctx, `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE avatar_thumb=VALUES(avatar_thumb)`, userID, local.Nickname, local.Title, local.Email, local.Bio, "[]", a.avatarThumb(), local.UpdatedAt); e == nil { _ = a.store.SetMeta("profile_avatar_pushed_at", avAt) } } }