Files
code-utils/profile.go
2026-08-14 07:52:01 +08:00

174 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}