267 lines
7.0 KiB
Go
267 lines
7.0 KiB
Go
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)
|
||
}
|