Files
code-utils/profile.go

174 lines
5.2 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
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 源缩到 64pxurl 源原样返回。
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)
}
}
}