Files
code-utils/profile.go
2026-08-15 17:18:00 +08:00

267 lines
7.0 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 与远端 /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 源缩到 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() {
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)
}