更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 deletions

View File

@@ -1,16 +1,15 @@
package main
// profile.go 用户公开资料:昵称/头衔/邮箱/简介/技术栈标签 + 头像缩略图。
// 本地存 meta离线可编辑登录同步时按 LWW 与远端 user_profiles 表推拉
// 供同服务器的团队成员互相查看(表结构见 init.sql
// 本地存 meta离线可编辑登录同步时按 LWW 与远端 /profile 推拉
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"os"
"strconv"
"strings"
)
@@ -23,7 +22,6 @@ type UserProfile struct {
UpdatedAt string `json:"updatedAt"`
}
// parseTechTags 解析技术栈标签 JSON 数组,坏数据回退空。
func parseTechTags(s string) []string {
out := []string{}
if e := json.Unmarshal([]byte(s), &out); e != nil {
@@ -135,39 +133,134 @@ func encodeThumb(raw []byte) string {
}
// syncUserProfile 在同步循环中推拉公开资料LWW并保持头像缩略图最新。
// 表缺失(服务器未升级)时静默跳过。
func (a *App) syncUserProfile(ctx context.Context, db *sql.DB, userID int64) {
func (a *App) syncUserProfile() {
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 {
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:
remote.TechTags = parseTechTags(remoteTags)
raw, _ := json.Marshal(remote)
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):
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 // 全量推送已带最新头像
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
}
// 头像单独变更:只刷新缩略图列,不动 updated_at文本字段 LWW 不受影响)。
// 头像单独变更:只刷新缩略图(服务端 Put 会带上 avatarThumb)。
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 {
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)
}