初始化项目

This commit is contained in:
zerosaturation 2026-04-07 22:28:50 +08:00
commit 0e6d0c3584
21 changed files with 4654 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/frontend/node_modules
/frontend/unpackage/
.DS_Store
frontend/static/.DS_Store
frontend/pages/.DS_Store
frontend/.hbuilderx/launch.json
.idea

1
README.md Normal file
View File

@ -0,0 +1 @@
# TopFans

22
docker/.env.local Normal file
View File

@ -0,0 +1,22 @@
# ===================================================================
# TopFans Docker - Local Development Environment Variables
# ===================================================================
# Copy this file to .env and modify values as needed
# Usage: docker-compose -f docker-compose.local.yml --profile local up -d
# ===================================================================
# Database (must match POSTGRES_PASSWORD in compose file)
DB_PASSWORD=postgres123
# JWT Secret - CHANGE IN PRODUCTION
JWT_SECRET=topfans-secret-key-local-dev-only
# OSS Configuration (from backend code defaults)
OSS_REGION=cn-shanghai
OSS_BUCKET_NAME=top-fans-test
OSS_STS_ROLE_ARN=acs:ram::1387642798143585:role/top-fans-oss-user
OSS_ACCESS_KEY_ID=LTAI5tNaAjTNiHnefMCG3q4J
OSS_ACCESS_KEY_SECRET=48wwZvNkUn1PO1xWjV4HuE5JjB6G7c
OSS_AVATAR_DIR=avatar/
OSS_ASSET_DIR=asset/
OSS_TOKEN_EXPIRE_TIME=3600

22
docker/.env.prod Normal file
View File

@ -0,0 +1,22 @@
# ===================================================================
# TopFans Docker - Production Environment Variables
# ===================================================================
# IMPORTANT: Change all secrets before deploying to production
# Usage: docker-compose -f docker-compose.prod.yml --profile prod up -d
# ===================================================================
# Database - MUST CHANGE
DB_PASSWORD=CHANGE_ME_TO_A_SECURE_PASSWORD
# JWT Secret - MUST CHANGE
JWT_SECRET=CHANGE_ME_TO_A_VERY_LONG_RANDOM_STRING
# OSS Configuration
OSS_REGION=cn-shanghai
OSS_BUCKET_NAME=top-fans-prod
OSS_STS_ROLE_ARN=acs:ram::1387642798143585:role/top-fans-oss-user
OSS_ACCESS_KEY_ID=YOUR_OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET=YOUR_OSS_ACCESS_KEY_SECRET
OSS_AVATAR_DIR=avatar/
OSS_ASSET_DIR=asset/
OSS_TOKEN_EXPIRE_TIME=3600

137
docker/Dockerfile.services Normal file
View File

@ -0,0 +1,137 @@
# ===================================================================
# TopFans - Multi-stage Build for Go Services
# Stage 1: Build all services in parallel
# Stage 2: Create minimal runtime image for each service
# ===================================================================
# ---- Build Stage ----
FROM --platform=linux/amd64 golang:1.25-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git make
# Set working directory
WORKDIR /build
# Copy go mod files first for better caching
COPY backend/go.mod backend/go.sum ./
# 配置 Go 模块代理,解决网络问题
RUN go env -w GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/go-proxy/,direct && \
go env -w GOSUMDB=off && \
go mod download
# Copy source code
COPY backend/ ./
# Build all services with optimization flags
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/gateway gateway/main.go && \
echo "Built gateway" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/userservice services/userService/main.go && \
echo "Built userservice" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/socialservice services/socialService/main.go && \
echo "Built socialservice" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/assetservice services/assetService/main.go && \
echo "Built assetservice" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/galleryservice services/galleryService/main.go && \
echo "Built galleryservice" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
-o /tmp/activityservice services/activityService/main.go && \
echo "Built activityservice"
# ---- Runtime Stage: Gateway ----
FROM --platform=linux/amd64 alpine:3.19 AS gateway
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/gateway /app/gateway
ENV GIN_MODE=release
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["/app/gateway"]
CMD ["-port=8080"]
# ---- Runtime Stage: UserService ----
FROM --platform=linux/amd64 alpine:3.19 AS userservice
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/userservice /app/userservice
EXPOSE 20000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:20000 || exit 1
ENTRYPOINT ["/app/userservice"]
# ---- Runtime Stage: SocialService ----
FROM --platform=linux/amd64 alpine:3.19 AS socialservice
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/socialservice /app/socialservice
EXPOSE 20002
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:20002 || exit 1
ENTRYPOINT ["/app/socialservice"]
# ---- Runtime Stage: AssetService ----
FROM --platform=linux/amd64 alpine:3.19 AS assetservice
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/assetservice /app/assetservice
EXPOSE 20003
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:20003 || exit 1
ENTRYPOINT ["/app/assetservice"]
# ---- Runtime Stage: GalleryService ----
FROM --platform=linux/amd64 alpine:3.19 AS galleryservice
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/galleryservice /app/galleryservice
EXPOSE 20004
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:20004 || exit 1
ENTRYPOINT ["/app/galleryservice"]
# ---- Runtime Stage: ActivityService ----
FROM --platform=linux/amd64 alpine:3.19 AS activityservice
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /tmp/activityservice /app/activityservice
EXPOSE 20005
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:20005 || exit 1
ENTRYPOINT ["/app/activityservice"]

258
docker/build.sh Executable file
View File

@ -0,0 +1,258 @@
#!/bin/bash
# ===================================================================
# TopFans Docker 一键构建脚本
# 功能:编译并打包所有后端服务为 Docker 镜像
# ===================================================================
#
# 使用方式:
# ./build.sh # 构建所有服务(默认)
# ./build.sh --no-cache # 无缓存构建
# ./build.sh gateway # 仅构建 Gateway
# ./build.sh userService # 仅构建 UserService
# ./build.sh prod # 使用生产配置构建
#
# 示例:
# ./build.sh # 构建所有服务
# ./build.sh --no-cache # 清除缓存重新构建
# ./build.sh gateway userService # 仅构建指定服务
# ===================================================================
set -e # 遇到错误立即退出
# ==================== 颜色定义 ====================
# 用于终端输出彩色文字,提升可读性
RED='\033[0;31m' # 红色 - 错误信息
GREEN='\033[0;32m' # 绿色 - 成功信息
YELLOW='\033[1;33m' # 黄色 - 警告/进度信息
BLUE='\033[0;34m' # 蓝色 - 标题信息
NC='\033[0m' # 重置颜色
# ==================== 路径配置 ====================
# 获取脚本所在目录(支持从任意目录调用)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ==================== 构建配置 ====================
# 镜像前缀,用于标记和组织镜像(必须小写)
IMAGE_PREFIX="topfans"
# Dockerfile 路径
DOCKERFILE="Dockerfile.services"
# 目标架构amd64 适用于大多数服务器)
TARGET_ARCH="linux/amd64"
# ==================== 默认设置 ====================
# 是否使用缓存(默认使用,构建更快)
NO_CACHE=""
# 构建配置文件local 或 prod
PROFILE="local"
# 要构建的服务列表(留空则构建全部)
SERVICES=()
# ==================== 解析命令行参数 ====================
# 支持多种使用方式
# --no-cache: 清除 Docker 缓存,强制重新构建
# --profile prod: 使用生产配置
# 服务名: 仅构建指定服务(如 gateway, userService 等)
while [[ $# -gt 0 ]]; do
case $1 in
--no-cache)
# 启用无缓存构建,适用于代码更新后确保完全重新编译
NO_CACHE="--no-cache"
echo -e "${YELLOW}警告: 无缓存模式,构建时间较长${NC}"
shift
;;
--profile)
# 指定构建配置local 或 prod
PROFILE="$2"
shift 2
;;
--help|-h)
# 显示帮助信息
echo "用法: $0 [选项] [服务名...]"
echo ""
echo "选项:"
echo " --no-cache 不使用 Docker 缓存,强制重新构建"
echo " --profile <env> 使用配置文件 (local/prod),默认 local"
echo " --help, -h 显示此帮助信息"
echo ""
echo "服务名 (可选):"
echo " gateway, userService, socialService, assetService,"
echo " galleryService, activityService"
echo ""
echo "示例:"
echo " $0 # 构建所有服务"
echo " $0 --no-cache # 无缓存构建所有服务"
echo " $0 gateway # 仅构建 Gateway"
echo " $0 --profile prod # 使用生产配置构建"
exit 0
;;
*)
# 未知参数,假设是服务名
# 支持的服务名映射(别名 -> 实际 target 名)
case $1 in
gateway) SERVICES+=("gateway") ;;
user|userService) SERVICES+=("userService") ;;
social|socialService) SERVICES+=("socialService") ;;
asset|assetService) SERVICES+=("assetService") ;;
gallery|galleryService) SERVICES+=("galleryService") ;;
activity|activityService) SERVICES+=("activityService") ;;
all)
# all 关键字,构建所有服务
SERVICES=()
break
;;
*)
echo -e "${RED}错误: 未知服务 '$1'${NC}"
echo "使用 --help 查看可用服务列表"
exit 1
;;
esac
shift
;;
esac
done
# ==================== 服务列表 ====================
# 所有可用服务及其配置(使用小写 target 名)
ALL_SERVICES_NAME=("gateway" "userservice" "socialservice" "assetservice" "galleryservice" "activityservice")
# 确定要构建的服务
if [ ${#SERVICES[@]} -eq 0 ]; then
# 未指定服务,构建全部
SERVICE_NAMES=("${ALL_SERVICES_NAME[@]}")
else
SERVICE_NAMES=("${SERVICES[@]}")
fi
# ==================== 构建函数 ====================
# 打印分隔线
print_line() {
echo -e "${BLUE}========================================${NC}"
}
# 打印带颜色的消息
print_msg() {
local color=$1
local msg=$2
echo -e "${color}${msg}${NC}"
}
# 构建单个服务的镜像
# 参数: service_name docker_target
build_service() {
local service_name=$1
local docker_target=$2
echo -e "\n${YELLOW}正在构建 ${service_name}...${NC}"
# 检查 Dockerfile 是否存在
if [ ! -f "$DOCKERFILE" ]; then
echo -e "${RED}错误: Dockerfile ($DOCKERFILE) 不存在${NC}"
return 1
fi
# 构建镜像
# 镜像命名: topfans/gateway:latest, topfans/userService:latest 等
# -f: 指定 Dockerfile
# --target: 指定构建阶段(对应 Dockerfile 中的 AS xxx
# --platform: 指定目标平台
# $NO_CACHE: 是否使用缓存
docker build \
$NO_CACHE \
-f "$DOCKERFILE" \
--target "$docker_target" \
--platform "$TARGET_ARCH" \
-t "${IMAGE_PREFIX}/${service_name}:latest" \
../
if [ $? -eq 0 ]; then
print_msg "$GREEN" "${service_name} 构建成功"
return 0
else
print_msg "$RED" "${service_name} 构建失败"
return 1
fi
}
# ==================== 主构建流程 ====================
main() {
print_line
print_msg "$BLUE" " TopFans Docker 构建脚本"
print_msg "$BLUE" " 配置: ${PROFILE}"
print_msg "$BLUE" " 架构: ${TARGET_ARCH}"
print_line
# 记录开始时间
START_TIME=$(date +%s)
# 统计构建结果
SUCCESS_COUNT=0
FAIL_COUNT=0
# 构建每个服务
for service in "${SERVICE_NAMES[@]}"; do
# 服务名到 target 的映射(统一使用小写 target
case $service in
gateway) docker_target="gateway" ;;
userservice) docker_target="userservice" ;;
socialservice) docker_target="socialservice" ;;
assetservice) docker_target="assetservice" ;;
galleryservice) docker_target="galleryservice" ;;
activityservice) docker_target="activityservice" ;;
# 兼容旧的大写服务名
userService) docker_target="userservice" ;;
socialService) docker_target="socialservice" ;;
assetService) docker_target="assetservice" ;;
galleryService) docker_target="galleryservice" ;;
activityService) docker_target="activityservice" ;;
*) docker_target="$service" ;;
esac
if build_service "$service" "$docker_target"; then
((SUCCESS_COUNT++))
else
((FAIL_COUNT++))
fi
done
# ==================== 构建结果 ====================
print_line
print_msg "$BLUE" " 构建完成"
# 计算耗时
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
MINUTES=$((ELAPSED / 60))
SECONDS=$((ELAPSED % 60))
echo ""
print_msg "$GREEN" "✅ 成功: ${SUCCESS_COUNT} 个服务"
if [ $FAIL_COUNT -gt 0 ]; then
print_msg "$RED" "❌ 失败: ${FAIL_COUNT} 个服务"
fi
echo -e "${YELLOW}⏱ 耗时: ${MINUTES}${SECONDS}${NC}"
print_line
# 显示构建的镜像
echo ""
print_msg "$BLUE" "构建的镜像列表:"
echo ""
for service in "${SERVICE_NAMES[@]}"; do
# 获取镜像大小
SIZE=$(docker images "${IMAGE_PREFIX}/${service}:latest" --format "{{.Size}}" 2>/dev/null || echo "未知")
echo -e " ${GREEN}${IMAGE_PREFIX}/${service}:latest${NC} (${SIZE})"
done
echo ""
print_msg "$GREEN" "🎉 构建完成!"
echo ""
echo "下一步:"
echo " 启动服务 (本地): docker-compose -f docker-compose.local.yml --profile local up -d"
echo " 启动服务 (生产): docker-compose -f docker-compose.prod.yml --profile prod up -d"
echo ""
}
# 执行主函数
main

597
docker/deploy.sh Executable file
View File

@ -0,0 +1,597 @@
#!/bin/bash
# ===================================================================
# TopFans 打包上传部署脚本
# 功能:
# - 本地:构建镜像 → 推送到镜像仓库
# - 远程:拉取镜像 → 启动服务
# - 回滚:回滚到指定版本
# ===================================================================
#
# 使用前提:
# 1. 已安装 Docker
# 2. 已创建阿里云容器镜像仓库
# 3. 服务器已配置 SSH 免密登录(建议)
#
# 使用方式:
# # 本地构建 + 推送
# ./deploy.sh build v1.0.0
#
# # 远程部署(从仓库拉取 + 启动)
# ./deploy.sh deploy v1.0.0
#
# # 回滚到指定版本
# ./deploy.sh rollback v1.0.0
#
# # 查看部署历史
# ./deploy.sh history
#
# # 一键构建 + 推送 + 部署(本地构建完成后远程部署)
# ./deploy.sh all v1.0.0
#
# ===================================================================
set -e
# ==================== 颜色定义 ====================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# ==================== 路径配置 ====================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ==================== 镜像仓库配置 ====================
# ⚠️ 需要修改为你的阿里云仓库地址
REGISTRY_HOST="registry.cn-hangzhou.aliyuncs.com"
NAMESPACE="你的命名空间" # ⚠️ 修改这里
# 服务列表(必须与 docker-compose 中的服务名一致)
SERVICES=(
"gateway"
"userservice"
"socialservice"
"assetservice"
"galleryservice"
"activityservice"
)
# ==================== 服务器配置 ====================
# ⚠️ 修改为你的服务器信息
SERVER_HOST="" # 服务器 IP 或域名
SERVER_PORT="22" # SSH 端口
SERVER_USER="root" # SSH 用户名
SERVER_PATH="/opt/topfans/docker" # 服务器上 docker 目录路径
# ==================== 打印函数 ====================
print_step() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
print_msg() {
local color=$1
local msg=$2
echo -e "${color}${msg}${NC}"
}
# ==================== 帮助信息 ====================
show_help() {
cat << EOF
${MAGENTA}TopFans 部署脚本${NC}
${YELLOW}用法:${NC}
$0 <命令> [版本号] [选项]
${YELLOW}命令:${NC}
${GREEN}build${NC} <版本号> 本地构建镜像并推送到仓库
${GREEN}deploy${NC} <版本号> 远程部署(从仓库拉取 + 启动服务)
${GREEN}rollback${NC} <版本号> 回滚到指定版本
${GREEN}history${NC} 查看部署历史
${GREEN}all${NC} <版本号> 一键构建 + 推送 + 部署
${GREEN}clean${NC} 清理本地镜像(谨慎使用)
${YELLOW}选项:${NC}
--server <IP> 指定服务器(覆盖配置文件)
--skip-build 跳过构建(用于已构建过的情况)
--skip-push 跳过推送(用于已推送过的情况)
--force 强制执行(不确认)
--help, -h 显示此帮助
${YELLOW}示例:${NC}
$0 build v1.0.0 # 构建并推送
$0 deploy v1.0.0 --server 192.168.1.100 # 部署到服务器
$0 rollback v0.9.0 # 回滚到 v0.9.0
$0 history # 查看部署历史
$0 all v1.0.0 --server 192.168.1.100 # 一键完成所有操作
${YELLOW}前提准备:${NC}
1. 阿里云容器镜像: https://cr.console.aliyun.com/
2. 修改脚本中的 REGISTRY_HOST 和 NAMESPACE
3. 修改 SERVER_HOST 为你的服务器 IP
EOF
}
# ==================== 配置检查 ====================
check_config() {
local errors=0
if [ "$NAMESPACE" = "你的命名空间" ]; then
print_msg "$RED" "错误: 请修改 deploy.sh 中的 NAMESPACE 为你的阿里云仓库命名空间"
errors=$((errors + 1))
fi
if [ -z "$SERVER_HOST" ]; then
print_msg "$YELLOW" "警告: SERVER_HOST 未设置,远程部署功能将不可用"
fi
return $errors
}
# ==================== 1. 构建镜像 ====================
do_build() {
print_step "🔨 构建 Docker 镜像"
# 调用构建脚本
./build.sh --no-cache
if [ $? -ne 0 ]; then
print_msg "$RED" "❌ 构建失败"
exit 1
fi
print_msg "$GREEN" "✅ 镜像构建完成"
}
# ==================== 2. 推送镜像 ====================
do_push() {
local version=$1
print_step "🔑 登录镜像仓库"
# 登录(可能需要输入密码)
docker login --username="${NAMESPACE}" "${REGISTRY_HOST}" || {
print_msg "$RED" "❌ 登录失败"
exit 1
}
print_msg "$GREEN" "✅ 登录成功"
print_step "📦 推送镜像到仓库"
local failed=()
local pushed=()
for SERVICE in "${SERVICES[@]}"; do
local local_image="topfans/${SERVICE}:latest"
local remote_image="${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:v${version}"
local latest_image="${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:latest"
echo ""
print_msg "$YELLOW" "处理 ${SERVICE}..."
# 打标签
docker tag "${local_image}" "${remote_image}"
docker tag "${local_image}" "${latest_image}"
# 推送版本标签
echo -e " ${CYAN}${remote_image}${NC}"
if docker push "${remote_image}"; then
echo -e " ${GREEN}✅ 已推送${NC}"
pushed+=("${SERVICE}")
else
echo -e " ${RED}❌ 推送失败${NC}"
failed+=("${SERVICE}")
fi
done
# 推送 latest
echo ""
print_msg "$YELLOW" "推送 latest 标签..."
for SERVICE in "${SERVICES[@]}"; do
local latest_image="${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:latest"
docker push "${latest_image}" 2>/dev/null || true
done
print_step "📊 推送结果"
if [ ${#failed[@]} -eq 0 ]; then
print_msg "$GREEN" "✅ 全部推送成功"
else
print_msg "$RED" "❌ 失败: ${failed[*]}"
fi
echo ""
print_msg "$CYAN" "已推送的镜像: v${version}"
for SERVICE in "${pushed[@]}"; do
echo -e " ${GREEN}${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:v${version}${NC}"
done
return $([ ${#failed[@]} -eq 0 ] && echo 0 || echo 1)
}
# ==================== 3. 远程部署 ====================
do_deploy() {
local version=$1
if [ -z "$SERVER_HOST" ]; then
print_msg "$RED" "错误: 请设置 SERVER_HOST服务器 IP"
print_msg "$YELLOW" "使用方法: $0 deploy ${version} --server <IP>"
exit 1
fi
print_step "🚀 远程部署到 ${SERVER_HOST}"
# 构建远程部署脚本
local remote_script="
set -e
echo '=== 1. 创建部署目录 ==='
mkdir -p ${SERVER_PATH}
cd ${SERVER_PATH}
echo '=== 2. 登录镜像仓库 ==='
# 注意:需要提前在服务器上配置 docker login或者使用阿里云 AccessKey 登录
# 这里假设已配置免密登录或使用 docker-credential-ecr-login
echo '使用镜像: ${REGISTRY_HOST}/${NAMESPACE}'
echo '=== 3. 拉取镜像 ==='
for service in ${SERVICES[*]}; do
echo \"拉取 topfans-\$service:v${version}...\"
docker pull ${REGISTRY_HOST}/${NAMESPACE}/topfans-\$service:v${version}
done
echo '=== 4. 打 latest 标签 ==='
for service in ${SERVICES[*]}; do
docker tag ${REGISTRY_HOST}/${NAMESPACE}/topfans-\$service:v${version} \
${REGISTRY_HOST}/${NAMESPACE}/topfans-\$service:latest
done
echo '=== 5. 停止现有服务 ==='
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
echo '=== 6. 启动服务 ==='
docker-compose -f docker-compose.prod.yml --profile prod up -d
echo '=== 7. 等待服务就绪 ==='
sleep 10
echo '=== 8. 健康检查 ==='
curl -s http://localhost:8080/health > /dev/null && echo '✅ Gateway 健康' || echo '⚠️ Gateway 可能未就绪'
echo '=== 9. 记录部署历史 ==='
echo '{\"version\":\"${version}\",\"deployed_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"services\":${#SERVICES[@]}}' \
>> ${SERVER_PATH}/deploy_history.json
echo ''
echo '✅ 部署完成!'
docker-compose -f docker-compose.prod.yml ps
"
# 执行远程脚本
print_msg "$YELLOW" "正在连接 ${SERVER_USER}@${SERVER_HOST}..."
ssh -p "${SERVER_PORT}" -T "${SERVER_USER}@${SERVER_HOST}" << 'ENDSSH'
set -e
echo '=== 1. 检查 Docker 环境 ==='
if ! command -v docker &> /dev/null; then
echo '❌ Docker 未安装'
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo '❌ docker-compose 未安装'
exit 1
fi
echo '✅ Docker 环境就绪'
ENDSSH
# 由于 heredoc 在复杂脚本中有问题,这里简化为直接执行关键命令
print_msg "$YELLOW" "执行部署命令..."
# 分步执行远程命令
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
mkdir -p ${SERVER_PATH} && \
echo '目录就绪'
"
print_msg "$GREEN" "✅ 服务器目录就绪"
# 拉取镜像(逐个拉取以便查看进度)
for SERVICE in "${SERVICES[@]}"; do
print_msg "$YELLOW" "拉取 ${SERVICE}..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
docker pull ${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:v${version}
"
print_msg "$GREEN" "${SERVICE} 拉取完成"
done
# 打标签
print_msg "$YELLOW" "打 latest 标签..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
for service in ${SERVICES[*]}; do
docker tag ${REGISTRY_HOST}/${NAMESPACE}/topfans-\$service:v${version} \
${REGISTRY_HOST}/${NAMESPACE}/topfans-\$service:latest
done
echo '标签完成'
"
# 停止旧服务
print_msg "$YELLOW" "停止旧服务..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cd ${SERVER_PATH} && \
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
"
# 启动新服务
print_msg "$YELLOW" "启动服务..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cd ${SERVER_PATH} && \
docker-compose -f docker-compose.prod.yml --profile prod up -d
"
# 等待并检查
print_msg "$YELLOW" "等待服务启动 (15s)..."
sleep 15
print_step "📊 部署结果"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
echo ''
docker-compose -f docker-compose.prod.yml ps
echo ''
echo -n 'Gateway 健康检查: '
curl -s http://localhost:8080/health > /dev/null && echo '✅ OK' || echo '⚠️ 检查失败'
"
print_msg "$GREEN" "✅ 远程部署完成"
}
# ==================== 4. 回滚 ====================
do_rollback() {
local version=$1
if [ -z "$SERVER_HOST" ]; then
print_msg "$RED" "错误: 请设置 SERVER_HOST"
exit 1
fi
print_step "🔄 回滚到版本 v${version}"
print_msg "$YELLOW" "正在回滚 ${SERVER_HOST} 上的服务..."
# 停止服务
print_msg "$YELLOW" "停止服务..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cd ${SERVER_PATH} && \
docker-compose -f docker-compose.prod.yml down
"
# 拉取指定版本镜像并打标签
for SERVICE in "${SERVICES[@]}"; do
print_msg "$YELLOW" "拉取 ${SERVICE}:v${version}..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
docker pull ${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:v${version}
docker tag ${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:v${version} \
${REGISTRY_HOST}/${NAMESPACE}/topfans-${SERVICE}:latest
"
print_msg "$GREEN" "${SERVICE} 回滚完成"
done
# 启动服务
print_msg "$YELLOW" "启动服务..."
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cd ${SERVER_PATH} && \
docker-compose -f docker-compose.prod.yml --profile prod up -d
"
sleep 10
print_step "📊 回滚结果"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
echo ''
docker-compose -f docker-compose.prod.yml ps
echo ''
echo -n 'Gateway 健康检查: '
curl -s http://localhost:8080/health > /dev/null && echo '✅ OK' || echo '⚠️ 检查失败'
"
print_msg "$GREEN" "✅ 回滚完成!当前版本: v${version}"
}
# ==================== 5. 查看历史 ====================
do_history() {
if [ -z "$SERVER_HOST" ]; then
print_msg "$RED" "错误: 请设置 SERVER_HOST"
exit 1
fi
print_step "📜 部署历史"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
if [ -f ${SERVER_PATH}/deploy_history.json ]; then
cat ${SERVER_PATH}/deploy_history.json
else
echo '暂无部署历史'
fi
"
echo ""
print_msg "$YELLOW" "查看实时日志: ssh ${SERVER_USER}@${SERVER_HOST} 'docker-compose -f ${SERVER_PATH}/docker-compose.prod.yml logs -f'"
}
# ==================== 6. 清理本地镜像 ====================
do_clean() {
print_step "🧹 清理本地镜像"
print_msg "$RED" "警告: 将删除所有 topfans 镜像"
print_msg "$YELLOW" "列出当前镜像:"
docker images | grep topfans || echo "无 topfans 镜像"
echo ""
read -p "确认删除? (y/N): " confirm
if [ "$confirm" != "y" ]; then
print_msg "$YELLOW" "已取消"
exit 0
fi
docker images | grep topfans | awk '{print $3}' | xargs -r docker rmi -f
print_msg "$GREEN" "✅ 清理完成"
}
# ==================== 主函数 ====================
main() {
if [ $# -eq 0 ]; then
show_help
exit 0
fi
local command=$1
shift
local version=""
local skip_build=false
local skip_push=false
local force=false
# 解析剩余参数
while [[ $# -gt 0 ]]; do
case $1 in
--server)
SERVER_HOST="$2"
shift 2
;;
--skip-build)
skip_build=true
shift
;;
--skip-push)
skip_push=true
shift
;;
--force)
force=true
shift
;;
-h|--help)
show_help
exit 0
;;
v*)
version="${1#v}"
shift
;;
*)
echo -e "${RED}错误: 未知参数 '$1'${NC}"
exit 1
;;
esac
done
# 检查配置
check_config || true
case $command in
build)
# 构建 + 推送
if [ -z "$version" ]; then
echo -e "${RED}错误: 请指定版本号${NC}"
echo "用法: $0 build v1.0.0"
exit 1
fi
echo -e "${CYAN}版本: v${version}${NC}"
echo -e "${CYAN}仓库: ${REGISTRY_HOST}/${NAMESPACE}${NC}"
if [ "$skip_build" = false ]; then
do_build
fi
if [ "$skip_push" = false ]; then
do_push "$version"
fi
;;
deploy)
# 远程部署
if [ -z "$version" ]; then
echo -e "${RED}错误: 请指定版本号${NC}"
echo "用法: $0 deploy v1.0.0"
exit 1
fi
echo -e "${CYAN}版本: v${version}${NC}"
echo -e "${CYAN}目标: ${SERVER_USER}@${SERVER_HOST}${NC}"
do_deploy "$version"
;;
rollback)
# 回滚
if [ -z "$version" ]; then
echo -e "${RED}错误: 请指定要回滚的版本${NC}"
echo "用法: $0 rollback v1.0.0"
exit 1
fi
echo -e "${RED}⚠️ 确认回滚到 v${version}?${NC}"
[ "$force" = false ] && read -p "确认? (y/N): " confirm && [ "$confirm" != "y" ] && exit 0
do_rollback "$version"
;;
history)
do_history
;;
clean)
do_clean
;;
all)
# 构建 + 推送 + 部署
if [ -z "$version" ]; then
echo -e "${RED}错误: 请指定版本号${NC}"
exit 1
fi
if [ -z "$SERVER_HOST" ]; then
echo -e "${RED}错误: 请设置 SERVER_HOST 或使用 --server 参数${NC}"
exit 1
fi
echo -e "${MAGENTA}一键部署流程:${NC}"
echo " 1. 构建镜像"
echo " 2. 推送到仓库"
echo " 3. 部署到 ${SERVER_HOST}"
echo ""
[ "$force" = false ] && read -p "继续? (y/N): " confirm && [ "$confirm" != "y" ] && exit 0
do_build
do_push "$version"
do_deploy "$version"
print_step "🎉 全部完成!"
;;
*)
echo -e "${RED}错误: 未知命令 '$command'${NC}"
show_help
exit 1
;;
esac
}
main "$@"

View File

@ -0,0 +1,240 @@
# ===================================================================
# TopFans Docker Compose - Local Development (8G+ RAM)
# ===================================================================
# Usage:
# docker-compose -f docker-compose.local.yml --profile local up -d
# ===================================================================
x-common-env: &common-env
GIN_MODE: debug
ENV: development
LOG_LEVEL: info
DB_HOST: host.docker.internal
DB_PORT: 15432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-123456}
DB_NAME: ${DB_NAME:-top-fans}
DB_SSLMODE: disable
x-healthcheck: &healthcheck
test: ["CMD-SHELL", "nc -z localhost 20000 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
services:
# ==================== Dubbo Services ====================
# Start with UserService (root service - all others depend on it)
userservice:
image: topfans/userservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: userservice
container_name: topfans-userservice
restart: unless-stopped
environment:
<<: *common-env
PORT: 20000
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- topfans-net
expose:
- "20000"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 20000 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# Dubbo services (can start in parallel after UserService is ready)
assetservice:
image: topfans/assetservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: assetservice
container_name: topfans-assetservice
restart: unless-stopped
environment:
<<: *common-env
PORT: 20003
DUBBO_USER_SERVICE_URL: tri://userservice:20000
OSS_REGION: ${OSS_REGION:-cn-shanghai}
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-top-fans-test}
OSS_STS_ROLE_ARN: ${OSS_STS_ROLE_ARN:-acs:ram::1387642798143585:role/top-fans-oss-user}
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
OSS_AVATAR_DIR: ${OSS_AVATAR_DIR:-avatar/}
OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
OSS_TOKEN_EXPIRE_TIME: ${OSS_TOKEN_EXPIRE_TIME:-3600}
depends_on:
userservice:
condition: service_healthy
networks:
- topfans-net
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "20003"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 20003 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
socialservice:
image: topfans/socialservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: socialservice
container_name: topfans-socialservice
restart: unless-stopped
environment:
<<: *common-env
PORT: 20002
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
networks:
- topfans-net
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "20002"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 20002 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
galleryservice:
image: topfans/galleryservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: galleryservice
container_name: topfans-galleryservice
restart: unless-stopped
environment:
<<: *common-env
PORT: 20004
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
networks:
- topfans-net
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "20004"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 20004 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
activityservice:
image: topfans/activityservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: activityservice
container_name: topfans-activityservice
restart: unless-stopped
environment:
<<: *common-env
PORT: 20005
DUBBO_USER_SERVICE_URL: tri://userservice:20000
depends_on:
userservice:
condition: service_healthy
networks:
- topfans-net
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "20005"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 20005 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# ==================== API Gateway ====================
gateway:
image: topfans/gateway:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: gateway
container_name: topfans-gateway
restart: unless-stopped
environment:
<<: *common-env
SERVER_PORT: 8080
JWT_SECRET: ${JWT_SECRET:-topfans-secret-key-please-change-in-production}
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_SOCIAL_SERVICE_URL: tri://socialservice:20002
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
DUBBO_GALLERY_SERVICE_URL: tri://galleryservice:20004
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20005
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
socialservice:
condition: service_healthy
galleryservice:
condition: service_healthy
activityservice:
condition: service_healthy
networks:
- topfans-net
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 8080 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
networks:
topfans-net:
driver: bridge

View File

@ -0,0 +1,306 @@
# ===================================================================
# TopFans Docker Compose - Production (4G RAM, 2 CPU)
# ===================================================================
# Usage:
# docker-compose -f docker-compose.prod.yml --profile prod up -d
# ===================================================================
x-common-env: &common-env
GIN_MODE: release
ENV: production
LOG_LEVEL: info
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
DB_SSLMODE: disable
x-postgres-env: &postgres-env
POSTGRES_DB: topfans
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres123}
POSTGRES_MAX_CONNECTIONS: 100
POSTGRES_SHARED_BUFFERS: 128MB
POSTGRES_WORK_MEM: 8MB
POSTGRES_MAINTENANCE_WORK_MEM: 64MB
POSTGRES_EFFECTIVE_CACHE_SIZE: 512MB
POSTGRES_CHECKPOINT_COMPLETION_TARGET: 0.9
POSTGRES_WAL_BUFFERS: 8MB
x-healthcheck: &healthcheck
interval: 15s
timeout: 10s
retries: 3
start_period: 30s
services:
# ==================== Database ====================
postgres:
image: postgres:latest
container_name: topfans-postgres
restart: always
environment:
<<: *postgres-env
volumes:
- postgres_data:/var/lib/postgresql
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
ports:
- "5432:5432"
networks:
- topfans-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d topfans"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 400M
reservations:
memory: 128M
# ==================== Dubbo Services ====================
userservice:
image: topfans/userservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: userservice
container_name: topfans-userservice
restart: always
environment:
<<: *common-env
PORT: 20000
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
depends_on:
postgres:
condition: service_healthy
networks:
- topfans-net
expose:
- "20000"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:20000 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 150M
cpus: '0.5'
reservations:
memory: 64M
cpus: '0.25'
assetservice:
image: topfans/assetservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: assetservice
container_name: topfans-assetservice
restart: always
environment:
<<: *common-env
PORT: 20003
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
DUBBO_USER_SERVICE_URL: tri://userservice:20000
OSS_REGION: ${OSS_REGION:-cn-shanghai}
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-top-fans-test}
OSS_STS_ROLE_ARN: ${OSS_STS_ROLE_ARN:-acs:ram::1387642798143585:role/top-fans-oss-user}
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
OSS_AVATAR_DIR: ${OSS_AVATAR_DIR:-avatar/}
OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
OSS_TOKEN_EXPIRE_TIME: ${OSS_TOKEN_EXPIRE_TIME:-3600}
depends_on:
userservice:
condition: service_healthy
networks:
- topfans-net
expose:
- "20003"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:20003 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 200M
cpus: '0.5'
reservations:
memory: 64M
cpus: '0.25'
socialservice:
image: topfans/socialservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: socialservice
container_name: topfans-socialservice
restart: always
environment:
<<: *common-env
PORT: 20002
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
networks:
- topfans-net
expose:
- "20002"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:20002 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 150M
cpus: '0.5'
reservations:
memory: 64M
cpus: '0.25'
galleryservice:
image: topfans/galleryservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: galleryservice
container_name: topfans-galleryservice
restart: always
environment:
<<: *common-env
PORT: 20004
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
networks:
- topfans-net
expose:
- "20004"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:20004 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 150M
cpus: '0.5'
reservations:
memory: 64M
cpus: '0.25'
activityservice:
image: topfans/activityservice:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: activityservice
container_name: topfans-activityservice
restart: always
environment:
<<: *common-env
PORT: 20005
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
DUBBO_USER_SERVICE_URL: tri://userservice:20000
depends_on:
userservice:
condition: service_healthy
networks:
- topfans-net
expose:
- "20005"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:20005 || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 100M
cpus: '0.5'
reservations:
memory: 32M
cpus: '0.25'
# ==================== API Gateway ====================
gateway:
image: topfans/gateway:latest
build:
context: ..
dockerfile: docker/Dockerfile.services
target: gateway
container_name: topfans-gateway
restart: always
environment:
<<: *common-env
SERVER_PORT: 8080
JWT_SECRET: ${JWT_SECRET:-topfans-secret-key-please-change-in-production}
DUBBO_USER_SERVICE_URL: tri://userservice:20000
DUBBO_SOCIAL_SERVICE_URL: tri://socialservice:20002
DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003
DUBBO_GALLERY_SERVICE_URL: tri://galleryservice:20004
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20005
depends_on:
userservice:
condition: service_healthy
assetservice:
condition: service_healthy
socialservice:
condition: service_healthy
galleryservice:
condition: service_healthy
activityservice:
condition: service_healthy
networks:
- topfans-net
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
<<: *healthcheck
deploy:
resources:
limits:
memory: 200M
cpus: '0.5'
reservations:
memory: 64M
cpus: '0.25'
networks:
topfans-net:
driver: bridge
volumes:
postgres_data:

401
docker/init-db.sql Normal file
View File

@ -0,0 +1,401 @@
-- ===================================================================
-- TopFans Database Initialization Script
-- ===================================================================
-- This script runs automatically when PostgreSQL container starts
-- ===================================================================
-- TopFans 数据库初始化脚本
-- 创建所有表结构和索引
-- 开启扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- 1. stars 表 - 明星信息表
-- ============================================
CREATE TABLE IF NOT EXISTS stars (
star_id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
tag VARCHAR(100),
name_en VARCHAR(100),
pic_url VARCHAR(500),
description TEXT,
identity_id VARCHAR(50) NOT NULL UNIQUE,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
-- ============================================
-- 2. users 表 - 用户表
-- ============================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
mobile VARCHAR(11) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
access_token TEXT,
token_expires_at BIGINT,
avatar_url VARCHAR(500),
global_wallet_address VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
-- ============================================
-- 3. fan_profiles 表 - 粉丝档案表
-- ============================================
CREATE TABLE IF NOT EXISTS fan_profiles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
nickname VARCHAR(50) NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
times INTEGER NOT NULL DEFAULT 1,
social INTEGER NOT NULL DEFAULT 0,
experience BIGINT NOT NULL DEFAULT 0,
coin_balance BIGINT NOT NULL DEFAULT 0,
crystal_balance BIGINT NOT NULL DEFAULT 0,
tags JSONB,
avatar_url VARCHAR(500),
starbook_limit INTEGER NOT NULL DEFAULT 3,
slot_limit INTEGER NOT NULL DEFAULT 3,
assets_count INTEGER NOT NULL DEFAULT 0,
chain_address VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_fan_profiles_user_star UNIQUE (user_id, star_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_fan_profiles_star_nickname ON fan_profiles(star_id, nickname);
CREATE INDEX IF NOT EXISTS idx_fan_profiles_user_id ON fan_profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_fan_profiles_star_id ON fan_profiles(star_id);
ALTER TABLE fan_profiles
ADD CONSTRAINT fk_fan_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_fan_profiles_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 4. assets 表 - 资产表(藏品)
-- ============================================
CREATE TABLE IF NOT EXISTS assets (
id BIGSERIAL PRIMARY KEY,
owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
cover_url VARCHAR(500) NOT NULL,
material_url VARCHAR(500),
description TEXT,
rarity INTEGER,
tags JSONB,
visibility VARCHAR(20) NOT NULL DEFAULT 'private',
status INTEGER NOT NULL DEFAULT 0,
tx_hash VARCHAR(100),
block_number BIGINT,
like_count INTEGER NOT NULL DEFAULT 0,
is_original BOOLEAN NOT NULL DEFAULT false,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
minted_at BIGINT,
deleted_at BIGINT,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS idx_assets_owner_star ON assets(owner_uid, star_id);
CREATE INDEX IF NOT EXISTS idx_assets_star_active ON assets(star_id, is_active);
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
CREATE INDEX IF NOT EXISTS idx_assets_tx_hash ON assets(tx_hash);
CREATE INDEX IF NOT EXISTS idx_assets_created_at ON assets(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_assets_deleted_at ON assets(deleted_at);
ALTER TABLE assets
ADD CONSTRAINT fk_assets_owner FOREIGN KEY (owner_uid) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_assets_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 5. mint_orders 表 - 铸造订单表
-- ============================================
CREATE TABLE IF NOT EXISTS mint_orders (
order_id VARCHAR(100) PRIMARY KEY,
user_id BIGINT NOT NULL,
asset_id BIGINT,
star_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
cost_crystal BIGINT DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
material_url VARCHAR(500),
name VARCHAR(100),
description TEXT,
material_type VARCHAR(50),
event VARCHAR(100),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
minted_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_mint_orders_user_star ON mint_orders(user_id, star_id);
CREATE INDEX IF NOT EXISTS idx_mint_orders_asset ON mint_orders(asset_id);
CREATE INDEX IF NOT EXISTS idx_mint_orders_status ON mint_orders(status);
CREATE INDEX IF NOT EXISTS idx_mint_orders_created_at ON mint_orders(created_at DESC);
ALTER TABLE mint_orders
ADD CONSTRAINT fk_mint_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_mint_orders_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_mint_orders_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 6. asset_likes 表 - 点赞记录表
-- ============================================
CREATE TABLE IF NOT EXISTS asset_likes (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
created_at BIGINT NOT NULL,
CONSTRAINT uk_asset_likes_user_asset UNIQUE (user_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_asset_likes_asset ON asset_likes(asset_id);
CREATE INDEX IF NOT EXISTS idx_asset_likes_user_star ON asset_likes(user_id, star_id);
ALTER TABLE asset_likes
ADD CONSTRAINT fk_asset_likes_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_asset_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_asset_likes_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 7. friendships 表 - 好友关系表
-- ============================================
CREATE TABLE IF NOT EXISTS friendships (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
friend_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'accepted',
remark VARCHAR(50),
intimacy INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_friendships_user_friend_star UNIQUE (user_id, friend_id, star_id)
);
CREATE INDEX IF NOT EXISTS idx_friendships_user_star_status ON friendships(user_id, star_id, status);
CREATE INDEX IF NOT EXISTS idx_friendships_user_star_created ON friendships(user_id, star_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_friendships_friend_star ON friendships(friend_id, star_id);
CREATE INDEX IF NOT EXISTS idx_friendships_list_query ON friendships(user_id, star_id, status, created_at DESC);
ALTER TABLE friendships
ADD CONSTRAINT fk_friendships_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_friendships_friend FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_friendships_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 8. friend_requests 表 - 好友请求表
-- ============================================
CREATE TABLE IF NOT EXISTS friend_requests (
id BIGSERIAL PRIMARY KEY,
from_user_id BIGINT NOT NULL,
to_user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
message VARCHAR(200),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
expires_at BIGINT,
processed_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_friend_requests_from_status ON friend_requests(from_user_id, status);
CREATE INDEX IF NOT EXISTS idx_friend_requests_to_status ON friend_requests(to_user_id, status);
CREATE INDEX IF NOT EXISTS idx_friend_requests_star ON friend_requests(star_id);
CREATE INDEX IF NOT EXISTS idx_friend_requests_users_star ON friend_requests(from_user_id, to_user_id, star_id);
CREATE INDEX IF NOT EXISTS idx_friend_requests_expires ON friend_requests(expires_at);
ALTER TABLE friend_requests
ADD CONSTRAINT fk_friend_requests_from_user FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_friend_requests_to_user FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_friend_requests_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 9. booth_slots 表 - 展位表
-- ============================================
CREATE TABLE IF NOT EXISTS booth_slots (
slot_id BIGSERIAL PRIMARY KEY,
host_profile_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
slot_index INTEGER NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'public',
is_enabled BOOLEAN DEFAULT false,
unlock_type VARCHAR(20),
unlock_value INTEGER,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_host_slot UNIQUE (host_profile_id, slot_index)
);
CREATE INDEX IF NOT EXISTS idx_user_star ON booth_slots(user_id, star_id);
CREATE INDEX IF NOT EXISTS idx_star_enabled ON booth_slots(star_id, is_enabled);
ALTER TABLE booth_slots
ADD CONSTRAINT fk_booth_slots_profile FOREIGN KEY (host_profile_id) REFERENCES fan_profiles(id) ON DELETE CASCADE;
-- ============================================
-- 10. exhibitions 表 - 展品展示表
-- ============================================
CREATE TABLE IF NOT EXISTS exhibitions (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL,
slot_id BIGINT NOT NULL,
host_profile_id BIGINT NOT NULL,
occupier_uid BIGINT NOT NULL,
occupier_star_id BIGINT NOT NULL,
start_time BIGINT NOT NULL,
expire_at BIGINT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_asset UNIQUE (asset_id)
);
CREATE INDEX IF NOT EXISTS idx_slot ON exhibitions(slot_id);
CREATE INDEX IF NOT EXISTS idx_host ON exhibitions(host_profile_id);
CREATE INDEX IF NOT EXISTS idx_occupier ON exhibitions(occupier_uid, occupier_star_id);
CREATE INDEX IF NOT EXISTS idx_expire ON exhibitions(expire_at);
ALTER TABLE exhibitions
ADD CONSTRAINT fk_exhibitions_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_exhibitions_slot FOREIGN KEY (slot_id) REFERENCES booth_slots(slot_id) ON DELETE CASCADE;
-- ============================================
-- 11. activities 表 - 运营活动表
-- ============================================
CREATE TABLE IF NOT EXISTS activities (
id BIGSERIAL PRIMARY KEY,
activity_type VARCHAR(50) NOT NULL,
title VARCHAR(100) NOT NULL,
description TEXT,
star_id BIGINT NOT NULL,
start_time BIGINT NOT NULL,
end_time BIGINT NOT NULL,
target_progress BIGINT NOT NULL DEFAULT 1000,
current_progress BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
stage_configs JSONB,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activities_star_id ON activities(star_id);
CREATE INDEX IF NOT EXISTS idx_activities_status ON activities(status);
CREATE INDEX IF NOT EXISTS idx_activities_start_end ON activities(start_time, end_time);
-- ============================================
-- 12. activity_items 表 - 活动道具表
-- ============================================
CREATE TABLE IF NOT EXISTS activity_items (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
item_type VARCHAR(50) NOT NULL,
item_name VARCHAR(50) NOT NULL,
icon_url VARCHAR(500),
crystal_cost INTEGER NOT NULL,
contribution_points INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_items_activity ON activity_items(activity_id);
CREATE INDEX IF NOT EXISTS idx_activity_items_type ON activity_items(item_type);
-- ============================================
-- 13. activity_contributions 表 - 用户活动贡献记录表
-- ============================================
CREATE TABLE IF NOT EXISTS activity_contributions (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
item_type VARCHAR(50) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
crystal_spent BIGINT NOT NULL,
contribution_points BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_contributions_activity ON activity_contributions(activity_id);
CREATE INDEX IF NOT EXISTS idx_activity_contributions_user_star ON activity_contributions(user_id, star_id);
CREATE INDEX IF NOT EXISTS idx_activity_contributions_created ON activity_contributions(created_at DESC);
-- ============================================
-- 14. activity_user_stats 表 - 用户活动贡献汇总表
-- ============================================
CREATE TABLE IF NOT EXISTS activity_user_stats (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
total_contribution BIGINT NOT NULL DEFAULT 0,
total_crystal_spent BIGINT NOT NULL DEFAULT 0,
total_items INTEGER NOT NULL DEFAULT 0,
last_contribute_at BIGINT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_activity_user_star UNIQUE (activity_id, user_id, star_id)
);
CREATE INDEX IF NOT EXISTS idx_activity_user_stats_activity ON activity_user_stats(activity_id);
CREATE INDEX IF NOT EXISTS idx_activity_user_stats_contribution ON activity_user_stats(activity_id, total_contribution DESC);
CREATE INDEX IF NOT EXISTS idx_activity_user_stats_user_star ON activity_user_stats(user_id, star_id);
-- ============================================
-- 外键约束 - Activity Tables
-- ============================================
ALTER TABLE activity_items
ADD CONSTRAINT fk_activity_items_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE;
ALTER TABLE activity_contributions
ADD CONSTRAINT fk_activity_contributions_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_activity_contributions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_activity_contributions_item FOREIGN KEY (item_id) REFERENCES activity_items(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_activity_contributions_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
ALTER TABLE activity_user_stats
ADD CONSTRAINT fk_activity_user_stats_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_activity_user_stats_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT fk_activity_user_stats_star FOREIGN KEY (star_id) REFERENCES stars(star_id) ON DELETE CASCADE;
-- ============================================
-- 注释说明
-- ============================================
COMMENT ON TABLE stars IS '明星信息表';
COMMENT ON TABLE users IS '用户表';
COMMENT ON TABLE fan_profiles IS '粉丝档案表';
COMMENT ON TABLE assets IS '资产表(藏品)';
COMMENT ON TABLE mint_orders IS '铸造订单表';
COMMENT ON TABLE asset_likes IS '点赞记录表';
COMMENT ON TABLE friendships IS '好友关系表';
COMMENT ON TABLE friend_requests IS '好友请求表';
COMMENT ON TABLE booth_slots IS '展位表';
COMMENT ON TABLE exhibitions IS '展品展示表';
COMMENT ON TABLE activities IS '运营活动表';
COMMENT ON TABLE activity_items IS '活动道具表';
COMMENT ON TABLE activity_contributions IS '用户活动贡献记录表';
COMMENT ON TABLE activity_user_stats IS '用户活动贡献汇总表';
-- ===================================================================
-- Initialization Complete
-- ===================================================================

183
docker/start.sh Executable file
View File

@ -0,0 +1,183 @@
#!/bin/bash
# ===================================================================
# TopFans Docker 一键启动脚本
# 功能:构建并启动所有后端服务
# ===================================================================
#
# 使用方式:
# ./start.sh # 构建并启动所有服务(本地)
# ./start.sh --profile prod # 使用生产配置
# ./start.sh --build-only # 仅构建,不启动
# ./start.sh --no-cache # 无缓存构建
#
# 示例:
# ./start.sh # 本地开发模式
# ./start.sh --profile prod # 生产部署模式
# ===================================================================
set -e
# ==================== 颜色定义 ====================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# ==================== 路径配置 ====================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ==================== 默认设置 ====================
PROFILE="local"
BUILD_ONLY=false
NO_CACHE=""
# ==================== 解析参数 ====================
while [[ $# -gt 0 ]]; do
case $1 in
--profile)
PROFILE="$2"
shift 2
;;
--build-only)
BUILD_ONLY=true
shift
;;
--no-cache)
NO_CACHE="--no-cache"
shift
;;
--help|-h)
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " --profile <env> 配置文件 (local/prod),默认 local"
echo " --build-only 仅构建,不启动服务"
echo " --no-cache 无缓存构建"
echo " --help, -h 显示帮助"
exit 0
;;
*)
echo -e "${RED}错误: 未知选项 '$1'${NC}"
exit 1
;;
esac
done
# ==================== 配置检查 ====================
COMPOSE_FILE="docker-compose.${PROFILE}.yml"
ENV_FILE=".env.${PROFILE}"
if [ ! -f "$COMPOSE_FILE" ]; then
echo -e "${RED}错误: 配置文件 $COMPOSE_FILE 不存在${NC}"
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo -e "${YELLOW}警告: $ENV_FILE 不存在,复制 .env.local 作为模板${NC}"
if [ -f ".env.local" ]; then
cp .env.local "$ENV_FILE"
else
echo -e "${RED}错误: 缺少环境配置文件${NC}"
exit 1
fi
fi
# ==================== 打印信息 ====================
print_step() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
# ==================== 停止现有容器 ====================
print_step "🛑 停止现有容器"
# 停止并移除旧容器
docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true
echo -e "${GREEN}✅ 已停止旧容器${NC}"
# ==================== 构建镜像 ====================
print_step "🔨 构建 Docker 镜像"
# 调用构建脚本
if [ -n "$NO_CACHE" ]; then
./build.sh --no-cache
else
./build.sh
fi
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 构建失败${NC}"
exit 1
fi
echo -e "${GREEN}✅ 镜像构建完成${NC}"
# 如果仅构建则退出
if [ "$BUILD_ONLY" = true ]; then
echo ""
echo -e "${GREEN}🎉 构建完成(未启动服务)${NC}"
exit 0
fi
# ==================== 启动服务 ====================
print_step "🚀 启动服务"
echo -e "${YELLOW}使用配置: ${PROFILE}${NC}"
echo -e "${YELLOW}配置文件: ${COMPOSE_FILE}${NC}"
echo ""
# 启动所有服务(-d 后台运行)
docker-compose -f "$COMPOSE_FILE" --profile ${PROFILE} up -d
# ==================== 等待健康检查 ====================
print_step "⏳ 等待服务就绪"
# 等待时间(秒)
WAIT_TIME=60
INTERVAL=5
ELAPSED=0
echo -e "${YELLOW}正在等待服务启动...${NC}"
while [ $ELAPSED -lt $WAIT_TIME ]; do
# 检查 Gateway 健康状态
if curl -s http://localhost:8080/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Gateway 就绪${NC}"
break
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
echo -e "${YELLOW} 已等待 ${ELAPSED}s...${NC}"
done
if [ $ELAPSED -ge $WAIT_TIME ]; then
echo -e "${RED}⚠️ 服务启动超时,请检查日志${NC}"
echo "查看日志: docker-compose -f ${COMPOSE_FILE} logs"
fi
# ==================== 显示结果 ====================
print_step "📊 服务状态"
docker-compose -f "$COMPOSE_FILE" ps
echo ""
print_step "🌐 服务地址"
echo ""
echo -e " ${CYAN}Gateway:${NC} http://localhost:8080"
echo -e " ${CYAN}Swagger UI:${NC} http://localhost:8080/swagger/index.html"
echo -e " ${CYAN}PostgreSQL:${NC} localhost:5432"
echo ""
echo -e "${GREEN}🎉 启动完成!${NC}"
echo ""
echo "常用命令:"
echo " 查看日志: docker-compose -f ${COMPOSE_FILE} logs -f"
echo " 查看状态: docker-compose -f ${COMPOSE_FILE} ps"
echo " 停止服务: docker-compose -f ${COMPOSE_FILE} down"
echo ""

366
docs/API_CALLS_SUMMARY.md Normal file
View File

@ -0,0 +1,366 @@
# TopFans Frontend 接口调用总览
> 范围:`frontend` 源码(排除 `unpackage/dist` 编译产物)。
## 1. 请求基础设施
- **统一入口**`frontend/utils/api.js` 的 `request(options)`,底层使用 `uni.request`
- **Base URL**`http://101.132.250.62:8080`(硬编码)。
- **鉴权注入**
- 登录/注册接口(`/api/v1/auth/login`、`/api/v1/auth/register`)不注入 token。
- 其余接口自动从 `uni.getStorageSync('access_token')` 注入 `Authorization: Bearer <token>`
- **统一响应解析**
- HTTP `401`:清理 `access_token`/`user``reLaunch` 到登录页,`reject(Error('登录已过期,请重新登录'))`。
- HTTP `200` 且有 `res.data.code`
- `code === 200``resolve(res.data)`。
- `code === 401/400/403`:同样清会话并跳登录。
- 其他业务码:`reject(Error(res.data.message || '请求失败'))`。
- 其他 HTTP 状态:`reject(Error(res.data?.message || 请求失败(statusCode)))`。
- **超时/重试**:未设置统一 `timeout`,无自动重试机制。
---
## 2. API 清单(函数名 / 参数 / 响应解析 / 用途)
### 2.1 认证与账号
#### `loginApi(mobile, password)`
- **Method/Path**`POST /api/v1/auth/login`
- **参数**`{ mobile, password }`
- **调用位置**
- `store/modules/user.js` -> `actions.login`
- `pages/login/login.vue` -> `handleLogin`(触发 `store.dispatch('user/login')`
- **响应解析**
- `res.code === 200 && res.data` 成功。
- 读取 `res.data.access_token`、`res.data.user` 写入本地缓存与 store。
- 若 `user.avatar_url` 存在,继续调用 `getOssPresignedUrlApi``res.data.url` 并本地缓存头像。
- **用途**登录并初始化会话token、用户信息、头像缓存
#### `registerApi(mobile, password, star_id, nickname)`
- **Method/Path**`POST /api/v1/auth/register`
- **参数**`{ mobile, password, star_id, nickname }`
- **调用位置**
- `store/modules/user.js` -> `actions.register`
- `pages/profile/selectRole.vue` -> `handleNext`(触发 `store.dispatch('user/register')`
- **响应解析**
- 成功同登录流程:读取 `access_token``user`,并尝试头像预缓存。
- 对 `409`(昵称冲突)做显式分支,向上抛 `{ code: 409, message }`
- **用途**:注册并建立登录态。
#### `getUserProfileApi()`
- **Method/Path**`GET /api/v1/auth/me`
- **参数**:无
- **调用位置**
- `pages/profile/profile.vue` -> `fetchUserInfo`
- **响应解析**
- 读取 `res.data` 字段:`uid`、`nickname`、`avatar_url`、`current_identity`、`assets_num`、`slot_limit`、`starbook_limit`、`blockchain_address` 等。
- 页面侧将 `current_identity` 转换为缓存结构 `fan_identity`,并回写本地 `user`
- **用途**:个人中心刷新用户主数据(以服务端为准)。
#### `updateNicknameApi(nickname)`
- **Method/Path**`PUT /api/v1/me/nickname`
- **参数**`{ nickname }`
- **调用位置**
- `pages/profile/profile.vue` -> `confirmChangeNickname`
- **响应解析**:检查 `res.code === 200` 后触发 `fetchUserInfo(true)` 全量刷新。
- **用途**:修改当前身份昵称。
#### `updatePasswordApi(oldPassword, newPassword)`
- **Method/Path**`POST /api/v1/account/password`
- **参数**`{ old_password, new_password }`
- **调用位置**
- `pages/profile/profile.vue` -> `confirmChangePassword`
- **响应解析**`res.code === 200` 后提示成功并强制登出、清缓存、跳登录页。
- **用途**:密码修改与会话重置。
#### `deleteAccountApi()`
- **Method/Path**`POST /api/user/delete-account`
- **参数**:无
- **调用位置**
- `pages/profile/profile.vue` -> `confirmDeleteAccount`
- **响应解析**`result.code === 200` 后执行登出流程并跳登录页。
- **用途**:注销账号。
#### `updateUserInfoApi(nickname)`
- **Method/Path**`POST /api/user/update`
- **参数**`{ nickname }`
- **调用位置**:当前源码未发现调用。
- **用途**:历史/预留接口(当前未接入)。
---
### 2.2 社交(好友关系)
#### `friendListApi(page = 1, pageSize = 10)`
- **Method/Path**`GET /api/v1/social/friends?page={page}&page_size={pageSize}`
- **参数**:分页参数
- **调用位置**
- `pages/components/FriendsContent.vue` -> `loadFriendList`
- **响应解析**
- 读取 `res.data.items`、`res.data.total`。
- 字段映射:`friend_id -> user_id``friend_nickname -> nickname``friend_fan_level -> fan_level``friend_avatar -> avatar_url`。
- 头像会再经 `getFriendAvatarRealUrl()``getOssPresignedUrlApi` 转真实 URL。
- **用途**:好友列表页及分页加载。
#### `searchUserApi(friendUserId)`
- **Method/Path**`GET /api/v1/social/search-user?friend_user_id={friendUserId}`
- **参数**:目标用户 IDUID
- **调用位置**
- `pages/components/FriendsContent.vue` -> `watch(searchUid, ...)` 防抖回调
- **响应解析**
- 成功读取 `res.data` 并解析 `res.data.avatar_url` 为预签名 URL。
- 失败时对 `404` 走“用户不存在”分支。
- **用途**:按 UID 搜索可添加的用户。
#### `sendFriendRequestApi(friendUserId)`
- **Method/Path**`POST /api/v1/social/friend-requests`
- **参数**`{ friend_user_id }`
- **调用位置**
- `pages/components/FriendsContent.vue` -> `confirmAddFriend`
- **响应解析**:调用成功后刷新“已发送请求”列表。
- **用途**:发送好友申请。
#### `getSentFriendRequestsApi(page = 1, pageSize = 10)`
- **Method/Path**`GET /api/v1/social/friend-requests?type=sent&status=pending&page={page}&page_size={pageSize}`
- **参数**:分页参数
- **调用位置**
- `pages/components/FriendsContent.vue` -> `fetchSentRequests`
- **响应解析**
- 读取 `res.data.items`
- 解析 `to_user_avatar_url` 为预签名 URL 后渲染。
- **用途**:我的已发送申请列表。
#### `getReceivedFriendRequestsApi(page = 1, pageSize = 10)`
- **Method/Path**`GET /api/v1/social/friend-requests?type=received&status=pending&page={page}&page_size={pageSize}`
- **参数**:分页参数
- **调用位置**
- `pages/components/FriendsContent.vue` -> `loadReceivedRequests`
- **响应解析**
- 读取 `res.data.items` 分页追加。
- 解析 `from_user_avatar_url` 为预签名 URL。
- **用途**:收到的好友请求列表。
#### `handleFriendRequestApi(requestId, action)`
- **Method/Path**`POST /api/v1/social/friend-requests/handle`
- **参数**`{ request_id, action }``action ∈ {accept, reject}`
- **调用位置**
- `pages/components/FriendsContent.vue` -> `handleAcceptRequest`
- `pages/components/FriendsContent.vue` -> `handleRejectRequest`
- **响应解析**:成功后本地移除对应请求项。
- **用途**:接受/拒绝好友请求。
#### `deleteFriendApi(friendUserId)`
- **Method/Path**`DELETE /api/v1/social/friends`
- **参数**`{ friend_user_id }`
- **调用位置**
- `pages/components/FriendsContent.vue` -> `confirmDeleteFriend`
- **响应解析**:成功后本地删除好友项并更新计数。
- **用途**:解除好友关系。
---
### 2.3 身份(粉丝身份)
#### `getFanIdentitiesApi()`
- **Method/Path**`GET /api/v1/fan-identities`
- **参数**:无
- **调用位置**
- `pages/profile/profile.vue` -> `handleSwitchRole`
- **响应解析**:读取 `res.data.items` 作为可选明星列表。
- **用途**:拉取可新增的粉丝身份候选。
#### `addFanIdentityApi(starId, nickname)`
- **Method/Path**`POST /api/v1/my/fan-identities`
- **参数**`{ star_id, nickname }`
- **调用位置**
- `pages/profile/profile.vue` -> `confirmAddIdentity`
- **响应解析**`res.code === 200` 后刷新用户信息。
- **用途**:新增某明星身份。
#### `getMyFanIdentitiesApi()`
- **Method/Path**`GET /api/v1/my/fan-identities`
- **参数**:无
- **调用位置**
- `pages/profile/profile.vue` -> `handleFanTagClick`
- **响应解析**:读取 `res.data.items``res.data.current_star_id`
- **用途**:身份切换前加载身份列表与当前身份。
#### `switchFanIdentityApi(newStarId)`
- **Method/Path**`POST /api/v1/my/fan-identities/switch`
- **参数**`{ new_star_id }`
- **调用位置**
- `pages/profile/profile.vue` -> `handleSwitchIdentity`
- **响应解析**
- 读取 `res.data.access_token` 覆盖本地 token。
- 读取 `res.data.current_identity``identity_id`、`identity_name`、`tag`、`level`)刷新 UI 与缓存。
- **用途**:切换当前生效身份并刷新会话上下文。
---
### 2.4 资产/NFT星册、详情、点赞
#### `getMyAssetsApi(page = 1, pageSize = 20)`
- **Method/Path**`GET /api/v1/assets/me/items?page={page}&page_size={pageSize}`
- **参数**:分页参数
- **调用位置**
- `pages/components/StarbookContent.vue` -> `loadAssetsList`
- `pages/exhibition/exhibition.vue` -> `handleAddAssetClick`
- **响应解析**
- 读取 `response.data.items`
- 常用字段:`asset_id`、`name`、`cover_url`、`tx_hash`、`like_count`、`status`。
- `cover_url` 会通过 `getAssetCoverRealUrl()` -> `getOssPresignedUrlApi(type=asset)` 转真实访问 URL。
- **用途**:我的藏品列表(星册页、展馆上架弹窗)。
#### `getAssetDetailApi(assetId)`
- **Method/Path**`GET /api/v1/assets/{assetId}`
- **参数**`assetId`
- **调用位置**
- `pages/components/StarbookContent.vue` -> `handleCardClick`
- `pages/exhibition/exhibition.vue` -> `handleNftClick`
- **响应解析**
- 读取 `response.data.asset` 作为详情对象。
- 点赞态兼容读取:`asset.is_liked`,部分调用还兜底 `response.data.is_liked`
- **用途**:点击藏品卡片时拉取完整详情。
#### `likeAssetApi(assetId)`
- **Method/Path**`POST /api/v1/social/assets/{assetId}/like`
- **参数**`assetId`
- **调用位置**
- `pages/components/NftDetailModal.vue` -> `handleLike`(点赞分支)
- **响应解析**`response.code === 200` 后本地 `isLiked=true`、`likeCount+1`。
- **用途**:点赞藏品。
#### `unlikeAssetApi(assetId)`
- **Method/Path**`DELETE /api/v1/social/assets/{assetId}/like`
- **参数**`assetId`
- **调用位置**
- `pages/components/NftDetailModal.vue` -> `handleLike`(取消点赞分支)
- **响应解析**`response.code === 200` 后本地 `isLiked=false`、`likeCount-1`。
- **用途**:取消点赞。
---
### 2.5 展馆(槽位、上架、下架)
#### `getMyGalleriesApi()`
- **Method/Path**`GET /api/v1/mygalleries`
- **参数**:无
- **调用位置**
- `pages/exhibition/exhibition.vue` -> `loadGallerySlots``isMyGallery === true` 分支)
- **响应解析**
- 读取 `response.data.gallery_owner_id`、`response.data.slots`。
- `slots``visibility(public/private)` 分板,并读取 `slot.asset``asset_id`、`cover_url`、`like_count`、`remain_time` 等)。
- **用途**:加载当前用户展馆槽位状态。
#### `getUserGalleriesApi(targetUid)`
- **Method/Path**`GET /api/v1/galleries/{targetUid}`
- **参数**:目标用户 UID
- **调用位置**
- `pages/exhibition/exhibition.vue` -> `loadGallerySlots``isMyGallery === false` 分支)
- **响应解析**:与 `getMyGalleriesApi` 同结构解析。
- **用途**:加载目标用户展馆。
#### `placeAssetToGalleryApi(assetId, galleryOwnerId, slotId)`
- **Method/Path**`POST /api/v1/galleries/place`
- **参数**`{ asset_id, gallery_owner_id, slot_id }`
- **调用位置**
- `pages/exhibition/exhibition.vue` -> `confirmPlaceAsset`
- **响应解析**`response.code === 200` 后关闭弹窗并重新拉取槽位数据。
- **用途**:上架藏品到指定展位槽位。
#### `removeAssetFromGalleryApi(slotId)`
- **Method/Path**`DELETE /api/v1/galleries/slots/{slotId}/asset`
- **参数**`slotId`
- **调用位置**
- `pages/exhibition/exhibition.vue` -> `confirmRemoveAsset`
- **响应解析**`response.code === 200` 后刷新展馆槽位。
- **用途**:下架槽位中的藏品。
---
### 2.6 OSS上传签名 / 预签名读取 / 头像更新)
#### `getOssSignatureApi(type)`
- **Method/Path**`GET /api/v1/assets/oss/signature?type={type}`
- **参数**`type`(实际使用:`avatar`、`asset`
- **调用位置**
- `pages/profile/profile.vue` -> `uploadAvatarToOss``type='avatar'`
- `pages/components/CastloveContent.vue` -> `uploadImageToOss``type='asset'`
- **响应解析**
- 头像上传与素材上传都会读取:
- `host`, `dir`, `policy`, `x_oss_credential`, `x_oss_date`, `security_token`, `signature`, `x_oss_signature_version`
- `type=asset` 时额外读取 `signRes.data.order_id`(铸造订单草稿 ID
- **用途**:向 OSS 直传前获取签名凭证。
#### `getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar')`
- **Method/Path**`GET /api/v1/assets/oss/presigned-url?file_name={encodeURIComponent(fileName)}&expires={expires}&type={type}`
- **参数**:文件名/路径、有效期、类型
- **调用位置**
- `store/modules/user.js` -> `actions.login`、`actions.register`
- `pages/profile/profile.vue` -> `handleAvatarUpdateSuccess`
- `pages/components/Avatar.vue` -> `fetchOwnAvatarWithCache`
- `utils/assetImageHelper.js` -> `getAssetCoverRealUrl`、`getFriendAvatarRealUrl`
- **响应解析**:通用读取 `res.data.url`;失败时回退默认图或空值。
- **用途**:将 OSS 对象标识转换为可读 URL头像、藏品封面
#### `updateAvatarApi(avatarUrl)`
- **Method/Path**`PUT /api/v1/me/avatar`
- **参数**`{ avatar_url }`
- **调用位置**
- `pages/profile/profile.vue` -> `uploadAvatarToOss`OSS 上传成功后)
- **响应解析**`updateRes.code === 200` 后更新本地 user 缓存、触发 `avatarUpdated` 事件并刷新组件。
- **用途**:头像上传成功后回写用户头像地址。
---
### 2.7 铸造Castlove
#### `createMintOrderApi(orderData)`
- **Method/Path**`POST /api/v1/assets/mints`
- **参数**`orderData`(页面构造字段)
- `name`, `event`, `description`, `material_type`, `material_url`, `rarity`, `tags`, `order_id`
- **调用位置**
- `pages/components/CastloveContent.vue` -> `handleConfirm`
- **响应解析**`response.code === 200` 后视为铸造成功,保存临时展示数据并跳转成功页。
- **用途**:提交铸造订单。
#### `deleteMintOrderApi(orderId)`
- **Method/Path**`DELETE /api/v1/assets/mints/{orderId}`
- **参数**`orderId`
- **调用位置**
- `pages/components/CastloveContent.vue` -> `handleBack`(用户确认返回时清理未完成订单)
- **响应解析**:仅调用,不依赖返回体字段(失败仅记录日志,不阻断返回流程)。
- **用途**:清理中断铸造产生的草稿订单。
---
## 3. 非封装直连网络调用(不经过 `request()`
### `uni.uploadFile`OSS 直传)
- **位置**
- `pages/profile/profile.vue` -> `uploadAvatarToOss`
- `pages/components/CastloveContent.vue` -> `uploadImageToOss`
- **前置**:先调 `getOssSignatureApi` 获取签名字段。
- **成功判定**`uploadRes.statusCode === 200 || 204`。
- **后续动作**
- 头像上传:调用 `updateAvatarApi` 回写业务系统头像地址。
- 藏品素材上传:生成 `uploadedImageUrl`,用于 `createMintOrderApi`
- **说明**:不走 `request()`,因此不受其统一鉴权/错误码分流逻辑约束。
### `uni.downloadFile`(头像文件缓存)
- **位置**
- `utils/avatarCache.js` -> `downloadAndCacheAvatar`
- **调用链**`getOssPresignedUrlApi -> downloadAndCacheAvatar(avatarUrl, realUrl) -> uni.downloadFile`
- **成功判定**`res.statusCode === 200`
- **用途**:将头像下载到本地并 `saveFile`,提升后续展示性能与稳定性。
---
## 4. 调用覆盖与观察
- `utils/api.js` 共导出 **31** 个 API 函数。
- 其中 **30 个已在源码业务层使用**`updateUserInfoApi` 当前未发现调用。
- 响应处理范式基本统一:
- 成功分支:`res.code === 200` + 按需读取 `res.data.*`
- 失败分支:依赖 `throw Error(message)`,页面显示 `error.message`

438
docs/PRD-Rankings.md Normal file
View File

@ -0,0 +1,438 @@
# TopFans 排行榜功能需求文档
## 1. 功能概述
### 1.1 产品背景
排行榜功能旨在提升用户活跃度和参与度,通过展示热门藏品和自制佳作榜单,激励用户创作和互动。
### 1.2 排行榜类型
| 排行类型 | 统计维度 | 数据来源 |
|----------|----------|----------|
| 热度排行 | 展示中 / 本月 / 全部 | 藏品点赞数 |
| 自制排行 | 展示中 / 本月 / 全部 | 自制藏品点赞数 |
> **注意**: 活动排行榜功能待后续单独设计
---
## 2. 数据模型设计
### 2.1 现有数据分析
**Asset (藏品表)**
- `LikeCount` - 点赞数(累计)
- `OwnerUID` - 拥有者ID
- `StarID` - 粉丝身份ID
- `CreatedAt` - 创建时间
**AssetLike (点赞记录表)**
- `AssetID` - 藏品ID
- `UserID` - 点赞用户ID
- `CreatedAt` - 点赞时间(用于本月统计)
**Exhibition (展品展示表)**
- `AssetID` - 展品ID
- `StartTime` - 开始展示时间
- `ExpireAt` - 过期时间(用于"展示中"判定)
### 2.2 新增数据模型
#### 2.2.1 藏品表扩展
在 Asset 表中增加 `IsOriginal` 字段true=自制藏品):
```go
type Asset struct {
// ... 现有字段
IsOriginal bool `gorm:"default:false;column:is_original"` // 是否自制藏品
}
```
---
## 3. API 接口设计
### 3.1 通用响应格式
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [],
"page": 1,
"page_size": 10,
"total": 100
}
}
```
### 3.2 热度排行榜
#### 3.2.1 获取热度排行榜
**接口**: GET /api/v1/rankings/hot
**认证**: 需认证 (JWT Token)
**Query 参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| dimension | string | 是 | 统计维度: `displaying`(展示中) / `month`(本月) / `total`(全部) |
| star_id | int64 | 否 | 粉丝身份ID筛选不传则返回当前身份数据 |
| page | int | 否 | 页码默认1 |
| page_size | int | 否 | 每页数量默认10 |
**返回示例**:
```json
{
"code": 200,
"message": "ok",
"data": {
"my_ranking": {
"rank": 5,
"asset_id": 1005,
"asset_name": "我的藏品",
"cover_url": "https://...",
"like_count": 88,
"status": "ranked"
},
"items": [
{
"rank": 1,
"asset_id": 1001,
"asset_name": "战战生贺",
"cover_url": "https://...",
"owner_uid": 12345,
"owner_nickname": "爱战战",
"like_count": 999,
"is_original": false
},
{
"rank": 2,
"asset_id": 1002,
"asset_name": "肖战同框",
"cover_url": "https://...",
"owner_uid": 12346,
"owner_nickname": "小飞侠",
"like_count": 888,
"is_original": true
}
],
"page": 1,
"page_size": 10,
"total": 100
}
}
```
**我的排名返回说明**:
| 状态 | rank | 说明 |
|------|------|------|
| 上榜 | 5 | 在 Top N 内,显示具体排名 |
| 未上榜 | null | 不在 Top N 内,显示差距 |
**上榜返回示例 (my_ranking)**:
```json
{
"my_ranking": {
"rank": 5,
"asset_id": 1005,
"asset_name": "我的藏品",
"cover_url": "https://...",
"like_count": 88,
"status": "ranked"
}
}
```
**未上榜返回示例 (my_ranking)**:
```json
{
"my_ranking": {
"rank": null,
"asset_id": 1005,
"asset_name": "我的藏品",
"cover_url": "https://...",
"like_count": 50,
"status": "unranked",
"diff_to_rank": 50
}
}
```
**统计逻辑**:
| 维度 | 说明 |
|------|------|
| `displaying` | 统计当前正在展示的藏品的点赞数Exhibition.ExpireAt > Now() |
| `month` | 统计当前自然月内2026年3月1日-3月31日获得的点赞数 |
| `total` | 统计藏品累计点赞数 |
**多藏品处理**:
- 用户有多个藏品时,返回排行最高的那个藏品排名
**分页规则**:
- 每次返回 10 条page_size 默认 10
- 榜单最多显示前 100 名(可配置)
---
### 3.3 自制排行榜
#### 3.3.1 获取自制排行榜
**接口**: GET /api/v1/rankings/original
**认证**: 需认证 (JWT Token)
**Query 参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| dimension | string | 是 | 统计维度: `displaying`(展示中) / `month`(本月) / `total`(全部) |
| star_id | int64 | 否 | 粉丝身份ID筛选不传则返回当前身份数据 |
| page | int | 否 | 页码默认1 |
| page_size | int | 否 | 每页数量默认10 |
**返回示例**:
```json
{
"code": 200,
"message": "ok",
"data": {
"my_ranking": {
"rank": 3,
"asset_id": 1002,
"asset_name": "自制海报",
"cover_url": "https://...",
"like_count": 666,
"status": "ranked"
},
"items": [
{
"rank": 1,
"asset_id": 1002,
"asset_name": "自制海报",
"cover_url": "https://...",
"owner_uid": 12346,
"owner_nickname": "小飞侠",
"like_count": 888,
"material_url": "https://...",
"is_original": true
}
],
"page": 1,
"page_size": 10,
"total": 50
}
}
```
**统计逻辑**:
- 仅统计 `is_original = true` 的自制藏品
- 其他维度逻辑同热度排行榜
---
## 4. 前端界面设计
### 4.1 排行榜入口
在首页广场添加"排行榜"Tab或在个人中心添加入口。
### 4.2 排行榜页面结构
```
┌─────────────────────────────┐
│ 排行榜 │
├─────────────────────────────┤
│ [热度] [自制] │ ← Tab 切换
├─────────────────────────────┤
│ │
│ 🥇 用户A 999 赞 │
│ [图片] │
│ │
│ 🥈 用户B 888 赞 │
│ [图片] │
│ │
│ 🥉 用户C 777 赞 │
│ [图片] │
│ │
├─────────────────────────────┤
│ [展示中] [本月] [全部] │ ← 维度切换
└─────────────────────────────┘
```
### 4.3 我的排名吸底展示
**上榜状态**:
```
┌─────────────────────────────┐
│ 排行榜 │
│ ... │
│ 8. 用户H 100 赞 │
│ [图片] │
│ 9. 用户I 99 赞 │
│ [图片] │
│ 10. 用户J 98 赞 │
│ [图片] │
├─────────────────────────────┤
│ 🏆 我的排名: 第5名 │
│ ❤️ [图片] 我的藏品 88赞 │
└─────────────────────────────┘
```
**未上榜状态**:
```
┌─────────────────────────────┐
│ 排行榜 │
│ ... │
│ 98. 用户H 60 赞 │
│ [图片] │
│ 99. 用户I 59 赞 │
│ [图片] │
│ 100. 用户J 58 赞 │
│ [图片] │
├─────────────────────────────┤
│ 😢 未上榜 │
│ 距离上榜还差 8 热度 │
│ [图片] 我的藏品 50赞 │
└─────────────────────────────┘
```
**空状态**:
```
┌─────────────────────────────┐
│ 排行榜 │
├─────────────────────────────┤
│ │
│ 暂无数据,快去创作吧 │
│ │
│ [去铸造] │
└─────────────────────────────┘
```
---
## 5. 数据库变更
### 5.1 藏品表扩展
```sql
ALTER TABLE assets ADD COLUMN is_original BOOLEAN DEFAULT FALSE;
```
---
## 6. 性能优化
### 6.1 缓存策略
| 数据 | 缓存策略 | 过期时间 |
|------|----------|----------|
| 热度排行榜 | Redis ZSet | 5分钟 |
| 自制排行榜 | Redis ZSet | 5分钟 |
### 6.2 定时任务
1. **排行榜定时刷新** - 每5分钟更新一次排行榜缓存
2. **排名计算** - 每日凌晨重新计算精确排名
---
## 7. 业务流程
### 7.1 点赞时更新排行榜
```
用户点赞藏品
AssetLike 表新增记录 (记录点赞时间用于本月统计)
Asset.LikeCount +1
更新 Redis 热度/自制排行 ZSet
```
### 7.2 展示中藏品统计
```
定时任务扫描 Exhibition 表
筛选 ExpireAt > Now() 的记录
关联 Asset 表获取点赞数
更新 Redis 展示中排行 ZSet
```
### 7.3 本月统计
```
用户查询本月排行时
计算当月起始时间戳 (2026-03-01 00:00:00)
筛选 AssetLike.CreatedAt >= 月起始时间
按 AssetID 分组统计点赞数
返回排行结果
```
### 7.4 我的排名计算
```
用户请求排行榜
获取用户在该粉丝身份下所有藏品
找出点赞数最高的藏品
查询该藏品在榜单中的排名
如果不在 Top 100计算与第100名差距
返回 my_ranking 信息(含 cover_url
```
---
## 8. 待确认问题
1. **粉丝身份隔离**: 排行榜是否需要按粉丝身份隔离?
- ✅ 已确认:按当前用户的 star_id 过滤
2. **展示中判定**: 展位被占后4小时自动下架如何处理
- ✅ Exhibition.ExpireAt > Now() 即为展示中
3. **本月统计**: 自然月切换时如3月1日是否需要清理缓存
- 待定:建议跨月时刷新缓存
4. **Top N 默认值**: 默认显示前多少名?
- ✅ 已确认:默认 100
---
## 9. 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| V1.0 | 2026-03-11 | 初始版本 |
| V1.1 | 2026-03-12 | 移除活动排行榜,补充我的排名、差距计算、分页规则 |
| V1.2 | 2026-03-12 | my_ranking 补充 cover_url 字段 |

229
docs/PRD-activity.md Normal file
View File

@ -0,0 +1,229 @@
# 运营活动功能实现计划
## 背景
用户希望添加一个运营活动功能:
- 用户可以进入运营活动界面
- 通过水晶购买道具推进活动进程
- 购买道具可获得贡献点
- 后台统计贡献点排名
- 运营活动有时间限制
- 不同阶段展示不同界面效果
**现有代码基础**
- 前端已有应援活动页面 (`frontend/pages/support-activity/`)
- 已有活动配置 (`frontend/utils/activity-config.js`)
- 已有排行榜服务可作为参考 (`backend/services/assetService/service/ranking_service.go`)
- 水晶余额在 `fan_profiles.crystal_balance`
---
## 实现方案
### 1. 数据库设计
**✅ 已确认: 道具配置使用数据库方式**
新建表:`activities`, `activity_items`, `activity_contributions`, `activity_user_stats`
```sql
-- 运营活动表
CREATE TABLE IF NOT EXISTS activities (
id BIGSERIAL PRIMARY KEY,
activity_type VARCHAR(50) NOT NULL, -- 活动类型: birthday/concert/bus
title VARCHAR(100) NOT NULL, -- 活动标题
description TEXT, -- 活动描述
star_id BIGINT NOT NULL, -- 所属明星
start_time BIGINT NOT NULL, -- 开始时间
end_time BIGINT NOT NULL, -- 结束时间
target_progress BIGINT NOT NULL DEFAULT 1000, -- 目标进度
current_progress BIGINT NOT NULL DEFAULT 0, -- 当前进度
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/active/completed/expired
stage_configs JSONB, -- 阶段配置 (不同进度的背景图等)
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
-- 活动道具表 (数据库配置,支持运营平台动态调整)
CREATE TABLE IF NOT EXISTS activity_items (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
item_type VARCHAR(50) NOT NULL, -- 道具类型: firework/megaphone/love
item_name VARCHAR(50) NOT NULL, -- 道具名称
icon_url VARCHAR(500), -- 道具图标
crystal_cost INTEGER NOT NULL, -- 水晶消耗
contribution_points INTEGER NOT NULL, -- 贡献点奖励
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
-- 用户活动贡献记录表
CREATE TABLE IF NOT EXISTS activity_contributions (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
item_type VARCHAR(50) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
crystal_spent BIGINT NOT NULL,
contribution_points BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
-- 用户活动贡献汇总表 (用于排行榜)
CREATE TABLE IF NOT EXISTS activity_user_stats (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
total_contribution BIGINT NOT NULL DEFAULT 0,
total_crystal_spent BIGINT NOT NULL DEFAULT 0,
total_items INTEGER NOT NULL DEFAULT 0,
last_contribute_at BIGINT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_activity_user_star UNIQUE (activity_id, user_id, star_id)
);
```
### 2. Proto 定义
新建 `backend/proto/activity.proto`
```protobuf
// 活动相关消息定义
message Activity {
int64 id = 1;
string activity_type = 2;
string title = 3;
string description = 4;
int64 star_id = 5;
int64 start_time = 6;
int64 end_time = 7;
int64 target_progress = 8;
int64 current_progress = 9;
string status = 10;
string current_stage = 11; // 当前阶段: early/mid/late/completed
repeated ActivityItem items = 12;
}
message ActivityItem {
int64 id = 1;
string item_type = 2;
string item_name = 3;
string icon_url = 4;
int32 crystal_cost = 5;
int32 contribution_points = 6;
}
// 贡献购买请求
message PurchaseItemRequest {
int64 activity_id = 1;
string item_type = 2;
int32 quantity = 3;
int64 star_id = 4;
}
// 贡献点排名
message ContributionRankingRequest {
int64 activity_id = 1;
int64 star_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ContributionRankingItem {
int32 rank = 1;
int64 user_id = 2;
string nickname = 3;
string avatar_url = 4;
int64 total_contribution = 5;
int64 total_crystal_spent = 6;
}
message MyContribution {
int32 rank = 1;
int64 total_contribution = 2;
int64 total_crystal_spent = 3;
string status = 4; // ranked/unranked
}
```
### 3. 后端服务实现
**新建服务**: `backend/services/activityService/`
- `main.go` - 服务入口
- `service/activity_service.go` - 业务逻辑
- `repository/activity_repository.go` - 数据库操作
- `provider/activity_provider.go` - Dubbo provider
**API 端点** (通过 gateway):
- `GET /api/v1/activities/:id` - 获取活动详情
- `GET /api/v1/activities/:id/items` - 获取活动道具列表
- `POST /api/v1/activities/:id/purchase` - 购买道具
- `GET /api/v1/activities/:id/ranking` - 贡献点排名
- `GET /api/v1/activities?star_id=:star_id` - 获取活动列表
**水晶扣减逻辑**:
1. 检查用户水晶余额是否足够
2. 扣减水晶 (`fan_profiles.crystal_balance`)
3. 增加活动进度
4. 记录贡献明细
5. 更新用户贡献汇总
6. 返回结果
### 4. 前端实现
**修改文件**:
- `frontend/utils/activity-config.js` - 添加动态阶段配置
- `frontend/pages/support-activity/index.vue` - 接入后端 API
- `frontend/pages/support-activity/components/ActionBar.vue` - 购买道具交互
- `frontend/utils/api.js` - 添加活动 API
**阶段展示逻辑**:
```
- 0-25%: 初期 (early) - 初始背景
- 26-50%: 中期 (mid) - 中期背景
- 51-75%: 后期 (late) - 后期背景
- 76-100%: 完成 (completed) - 完成背景 + 特效动画
```
**时间限制显示**:
- 活动未开始: 显示"距开始还有 X 天"
- 活动进行中: 显示"还剩 X 天 X 小时"
- 活动已结束: 显示"活动已结束"
---
## 关键文件路径
| 组件 | 文件路径 |
|------|----------|
| 数据库初始化 | `backend/scripts/init_database.sql` |
| Proto 定义 | `backend/proto/activity.proto` (新建) |
| 活动服务 | `backend/services/activityService/` (新建) |
| Gateway 路由 | `backend/gateway/controller/` (新增) |
| 前端活动配置 | `frontend/utils/activity-config.js` |
| 前端活动页面 | `frontend/pages/support-activity/index.vue` |
| 前端 API | `frontend/utils/api.js` |
---
## 验证方案
1. **数据库**: 确认表创建成功,索引正确
2. **后端 API**:
- 创建活动,验证返回
- 购买道具,验证水晶扣减和贡献点增加
- 获取排名,验证排序正确
3. **前端**:
- 活动页面加载正常
- 购买道具交互正常
- 不同阶段背景切换正常
- 倒计时显示正常
4. **整体流程**: 完整流程测试 - 购买道具 -> 贡献点增加 -> 排名更新

206
docs/PRD-my-profile.md Normal file
View File

@ -0,0 +1,206 @@
# 个人主页My ProfilePRD
## 1. 概述
### 1.1 产品介绍
个人主页是一个整合展示用户个人资料和藏品的页面,功能类似于微信个人主页。用户可以查看自己的个人主页,也可以通过链接查看他人的个人主页。
### 1.2 需求来源
用户需要一个综合展示页面,整合展示头像/昵称/等级/展品/点赞数等信息。
### 1.3 范围
- 新建独立页面 `my-profile.vue`
- 支持查看自己和他人的主页
- 复用现有组件和 API
---
## 2. 页面结构
### 2.1 整体布局
```
┌─────────────────────────────────┐
│ 顶部背景 │
├─────────────────────────────────┤
│ 用户信息卡片(固定) │
│ ┌─────┐ │
│ │头像 │ 昵称 | 等级徽章 │
│ │ │ UID | 粉丝标签 │
│ └─────┘ 全部点赞数: xxx │
├─────────────────────────────────┤
│ 藏品展品区(可滚动) │
│ ┌────┬────┬────┐ │
│ │NFT1│NFT2│NFT3│ ...(点击展开)│
│ └────┴────┴────┘ │
└─────────────────────────────────┘
```
### 2.2 模块说明
| 模块 | 说明 |
|------|------|
| 顶部背景 | 渐变背景图,与整体风格一致 |
| 用户信息卡片 | 头像、昵称、等级、粉丝标签(固定不滚动) |
| 藏品展品区 | NFT 藏品网格,可滚动展示 |
---
## 3. 功能详细说明
### 3.1 用户信息卡片
**展示字段**
| 字段 | 来源 | 说明 |
|------|------|------|
| 头像 | `avatar_url` | 用户头像,有等级边框 |
| 昵称 | `nickname` | 用户昵称 |
| UID | `uid` | 用户 ID |
| 等级 | `current_identity.level` | 粉丝等级 |
| 粉丝标签 | `current_identity.tag` | 如"小飞侠" |
| 全部点赞数 | `items[].like_count` 求和 | 藏品总点赞数 |
**交互说明**
- 仅展示,不可点击修改
- 头像使用 Avatar 组件,带等级徽章
### 3.2 藏品展品区
**展示字段**
| 字段 | 来源 | 说明 |
|------|------|------|
| 藏品封面 | `cover_url` | NFT 藏品图片 |
| 点赞数 | `like_count` | 藏品获得的点赞数 |
**布局说明**
- 3 列网格布局
- 显示数量根据藏品等级区分(后期实现)
- 显示点赞数标签
**交互说明**
- 点击"..." → 展开显示余下藏品
- 点击藏品卡片 → 跳转 `/pages/asset-detail/asset-detail?asset_id=${nft.asset_id}`
### 3.3 页面参数
| 参数 | 说明 |
|------|------|
| `target_uid` | 可选,指定查看的用户 ID |
**逻辑说明**
- 无 `target_uid` → 查看自己的主页
- 有 `target_uid` → 查看指定用户的主页
**页面标题**
- 查看自己 → "我的主页"
- 查看他人 → "{用户昵称}的主页"
---
## 4. API 调用
### 4.1 查看自己
| API | 用途 |
|-----|------|
| `getUserProfileApi()` | 获取当前用户资料 |
| `getMyAssetsApi(page, size)` | 获取我的藏品列表 |
### 4.2 查看他人
| API | 用途 |
|-----|------|
| `getUserInfoApi(userId)` | 获取指定用户信息(需新增) |
| `getUserGalleriesApi(targetUid)` | 获取他人全部藏品 |
### 4.3 API 位置
- 前端 API 文件:`frontend/utils/api.js`
- 后端已有路由:`GET /api/v1/users/:user_id`
---
## 5. 数据字段映射
| 页面展示 | API 字段 |
|---------|---------|
| 头像 | `avatar_url``current_identity.avatar_url` |
| 昵称 | `nickname` |
| 等级 | `current_identity.level` |
| 粉丝标签 | `current_identity.tag` |
| 藏品封面 | `items[].cover_url` |
| 单个点赞数 | `items[].like_count` |
| 全部点赞数 | `items[].like_count` 求和 |
---
## 6. 技术方案
### 6.1 文件结构
```
frontend/pages/my-profile/
└── my-profile.vue # 个人主页主文件
```
### 6.2 路由注册
`pages.json` 中注册路由:
```json
{
"path": "pages/my-profile/my-profile"
}
```
### 6.3 组件复用
| 组件 | 路径 | 用途 |
|------|------|------|
| Avatar | `pages/components/Avatar.vue` | 头像展示 |
**藏品展品区说明**
- 藏品展品区用于展示用户已有的藏品列表
- 需新建独立组件或直接在页面中实现网格布局
- 不复用 `NftCard` 组件(该组件用于藏品市场/列表页)
### 6.4 页面入口
- `profile.vue` 的"我的资产"区域可添加入口
- 可通过 `uni.navigateTo` 跳转
---
## 7. 实现步骤
1. 在 `frontend/pages/my-profile/` 下创建 `my-profile.vue`
2. 在 `pages.json` 注册路由
3. 在 `frontend/utils/api.js` 新增 `getUserInfoApi(userId)`
4. 实现用户信息卡片模块
5. 实现藏品展品区模块
6. 添加页面入口(可选)
---
## 8. 验收标准
- [ ] 页面可正常访问
- [ ] 用户头像、昵称、等级正确显示
- [ ] 粉丝标签正确显示
- [ ] 藏品网格正确显示
- [ ] 点赞数正确显示
- [ ] 支持 `target_uid` 查看他人主页
- [ ] 页面标题正确区分"我的主页"和"TA的主页"
---
## 9. 备注
### 9.1 后端数据限制
- 后端无浏览数/访问量字段
- 藏品展示使用点赞数替代
### 9.2 参考页面
- `profile.vue` - 用户信息展示参考
- `exhibition.vue` - 藏品网格布局参考

573
docs/PRD.md Normal file
View File

@ -0,0 +1,573 @@
# TopFans 产品需求文档 (PRD)
## 文档信息
| 项目 | 内容 |
|------|------|
| 项目名称 | TopFans |
| 版本 | V1.0 |
| 文档类型 | 产品需求文档 |
| 生成日期 | 2026-03-11 |
---
## 1. 项目概述
### 1.1 产品定位
TopFans 是一款基于 NFT 数字藏品技术的粉丝社交应用,连接明星与粉丝,提供藏品铸造、展馆展示、社交互动等核心功能。
### 1.2 目标用户
- 明星粉丝群体
- 数字藏品爱好者
- 社交娱乐用户
### 1.3 技术架构
#### 前端技术栈
| 技术 | 说明 |
|------|------|
| uni-app | 跨平台应用框架 |
| Vue 3 | 前端框架 |
| Vuex 4 | 状态管理 |
| HBuilderX | 构建工具 |
#### 后端技术栈
| 技术 | 说明 |
|------|------|
| Go 1.25.5 | 开发语言 |
| Gin | HTTP 网关 |
| Dubbo-go | 微服务框架 (Triple协议) |
| PostgreSQL | 数据库 |
| GORM | ORM框架 |
| JWT | 认证 |
| 阿里云 OSS | 对象存储 |
#### 微服务架构
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Mobile App (uni-app) │
└─────────────────────────────────┬───────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────────────────────────────────────┐
│ API Gateway (Gin :8080) │
│ • JWT认证 • 请求路由 • Swagger文档 │
└─────────┬──────────────────────┬───────────────────────┬───────────────┘
│ │ │
│ Dubbo RPC │ Dubbo RPC │ Dubbo RPC
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User Service │ │ Social Service │ │ Asset Service │
│ (:20000) │ │ (:20001) │ │ (:20003) │
│ │ │ │ │ │
│ • 注册/登录 │ │ • 好友管理 │ │ • 藏品铸造 │
│ • 粉丝身份 │ │ • 用户搜索 │ │ • OSS上传 │
│ • 密码管理 │ │ • 点赞功能 │ │ • 藏品查询 │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└─────────────────────┼──────────────────────┘
│ Dubbo RPC
┌─────────────────────────┐
│ Gallery Service │
│ (:20004) │
│ │
│ • 展馆管理 │
│ • 展位管理 │
│ • 藏品展示 │
└─────────────────────────┘
┌─────────────────────────┐
│ PostgreSQL │
│ + GORM │
└─────────────────────────┘
```
---
## 2. 功能模块总览
| 模块 | 核心功能 | 状态 |
|------|----------|------|
| 认证系统 | 注册、登录、Token管理 | ✅ 已实现 |
| 粉丝身份系统 | 身份选择、多身份切换 | ✅ 已实现 |
| 藏品系统 | 铸造、上传、详情、点赞 | ✅ 已实现 |
| 展馆系统 | 展位管理、藏品展示 | ✅ 已实现 |
| 社交系统 | 好友搜索、申请、删除 | ✅ 已实现 |
| 任务系统 | 任务列表、奖励领取 | 🔄 部分实现 |
| 新手引导 | 引导流程、状态管理 | 🔄 部分实现 |
---
## 3. 详细功能需求
### 3.1 认证系统 (Auth)
#### 3.1.1 用户注册
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/auth/register |
| 认证 | 无需认证 |
| 请求参数 | uid(手机号), password, fan_identity_id, nickname |
| 返回 | access_token, user基础信息, 当前身份信息 |
| 说明 | 注册时必须选择一个粉丝身份(nickname唯一性?) |
#### 3.1.2 用户登录
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/auth/login |
| 认证 | 无需认证 |
| 请求参数 | uid, password |
| 返回 | access_token, user基础信息 |
#### 3.1.3 获取当前用户
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/auth/me |
| 认证 | JWT Token |
| 返回 | uid, nickname, current_identity |
#### 3.1.4 修改密码
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/account/password |
| 认证 | JWT Token |
| 请求参数 | old_password, new_password |
#### 3.1.5 退出登录
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/auth/logout |
| 认证 | JWT Token |
#### 3.1.6 注销账号
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/user/delete-account |
| 认证 | JWT Token |
---
### 3.2 粉丝身份系统 (Fan Identity)
#### 3.2.1 获取可选粉丝身份列表
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/fan-identities |
| 认证 | 无需认证 |
| 查询参数 | keyword, page, page_size |
| 返回 | star_id, identity_id, name, tag |
#### 3.2.2 新增粉丝身份
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/my/fan-identities |
| 认证 | JWT Token |
| 请求参数 | identity_id |
| 说明 | 最多绑定2个身份 |
#### 3.2.3 获取我的粉丝身份列表
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/my/fan-identities |
| 认证 | JWT Token |
#### 3.2.4 切换粉丝身份
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/my/fan-identities/switch |
| 认证 | JWT Token |
| 请求参数 | new_star_id |
| 返回 | 新token, current_identity信息 |
---
### 3.3 个人信息 (Profile)
#### 3.3.1 获取个人信息
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/me/profile |
| 认证 | JWT Token |
| 返回 | uid, nickname, fan_identity, fan_level, starbook_limit, slot_limit, assets_num, crystal_balance |
#### 3.3.2 修改昵称
| 项目 | 内容 |
|------|------|
| 接口 | PUT /api/v1/me/nickname |
| 认证 | JWT Token |
| 请求参数 | nickname |
#### 3.3.3 更新头像
| 项目 | 内容 |
|------|------|
| 接口 | PUT /api/v1/me/avatar |
| 认证 | JWT Token |
| 请求参数 | avatar_url |
---
### 3.4 藏品系统 (Asset/Mint)
#### 3.4.1 获取OSS上传签名
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/assets/oss/signature |
| 认证 | JWT Token |
| 返回 | OSS上传所需的签名信息 |
#### 3.4.2 获取OSS预签名URL
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/assets/oss/presigned-url |
| 认证 | JWT Token |
| 请求参数 | object_key |
#### 3.4.3 创建铸造订单
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/assets/mints |
| 认证 | JWT Token |
| 请求参数 | name, type, pic_url, material |
| 返回 | mint_id, status, asset_id |
#### 3.4.4 删除铸造订单
| 项目 | 内容 |
|------|------|
| 接口 | DELETE /api/v1/assets/mints/{orderId} |
| 认证 | JWT Token |
#### 3.4.5 获取我的藏品列表
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/assets/me/items |
| 认证 | JWT Token |
| 查询参数 | page, page_size |
| 返回 | asset_id, name, cover_url, like_count, mint_status, on_chain |
#### 3.4.6 获取藏品详情
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/assets/{assetId} |
| 认证 | 公开 |
| 返回 | asset_id, name, type, cover_url, owner_uid, owner_nickname, like_count, on_chain |
#### 3.4.7 点赞藏品
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/social/assets/{assetId}/like |
| 认证 | JWT Token |
| 返回 | liked, like_count |
#### 3.4.8 取消点赞
| 项目 | 内容 |
|------|------|
| 接口 | DELETE /api/v1/social/assets/{assetId}/like |
| 认证 | JWT Token |
| 返回 | liked, like_count |
---
### 3.5 展馆系统 (Gallery)
#### 3.5.1 获取我的展馆
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/mygalleries |
| 认证 | JWT Token |
| 返回 | gallery_owner_id, slot_total, slots(含asset信息) |
#### 3.5.2 获取他人展馆
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/galleries/{targetUid} |
| 认证 | JWT Token |
| 返回 | 同上 |
| 说明 | 需校验粉丝身份一致才能访问 |
#### 3.5.3 展位展示藏品
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/galleries/place |
| 认证 | JWT Token |
| 请求参数 | asset_id, gallery_owner_id, slot_id |
| 返回 | status, occupied_until, occupier_uid |
#### 3.5.4 下架展位藏品
| 项目 | 内容 |
|------|------|
| 接口 | DELETE /api/v1/galleries/slots/{slotId}/asset |
| 认证 | JWT Token |
---
### 3.6 社交系统 (Social)
#### 3.6.1 搜索用户
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/social/search-user |
| 认证 | JWT Token |
| 查询参数 | keyword |
| 返回 | uid, nickname, is_friend |
#### 3.6.2 获取好友列表
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/social/friends |
| 认证 | JWT Token |
| 查询参数 | page, page_size |
| 返回 | uid, nickname, fan_level |
#### 3.6.3 发送好友请求
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/social/friend-requests |
| 认证 | JWT Token |
| 请求参数 | target_uid |
#### 3.6.4 获取已发送好友请求
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/social/friend-requests |
| 认证 | JWT Token |
| 查询参数 | type=sent |
#### 3.6.5 获取收到的好友请求
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/social/friend-requests |
| 认证 | JWT Token |
| 查询参数 | type=received |
#### 3.6.6 处理好友请求
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/social/friend-requests/handle |
| 认证 | JWT Token |
| 请求参数 | request_id, action(accept/reject) |
#### 3.6.7 删除好友
| 项目 | 内容 |
|------|------|
| 接口 | DELETE /api/v1/social/friends |
| 认证 | JWT Token |
| 请求参数 | friend_uid |
---
### 3.7 任务系统 (Task)
#### 3.7.1 获取任务列表
| 项目 | 内容 |
|------|------|
| 接口 | GET /api/v1/tasks |
| 认证 | JWT Token |
| 返回 | task_id, title, status, reward |
#### 3.7.2 领取任务奖励
| 项目 | 内容 |
|------|------|
| 接口 | POST /api/v1/tasks/claim |
| 认证 | JWT Token |
| 请求参数 | task_id |
---
## 4. 数据库模型
### 4.1 核心数据表
| 表名 | 说明 |
|------|------|
| users | 用户表 |
| stars | 明星信息表 |
| fan_profiles | 粉丝档案表(用户-明星关联) |
| assets | 资产/藏品表 |
| mint_orders | 铸造订单表 |
| asset_likes | 点赞记录表 |
| friendships | 好友关系表 |
| friend_requests | 好友请求表 |
| booth_slots | 展位表 |
| exhibitions | 展品展示表 |
---
## 5. 用户界面结构
### 5.1 页面路由
| 路由 | 页面 | 说明 |
|------|------|------|
| /pages/login/login | 登录页 | 用户登录 |
| /pages/register/register | 注册页 | 用户注册 |
| /pages/profile/setNickname | 设置昵称 | 注册时设置昵称 |
| /pages/profile/selectRole | 选择角色 | 选择粉丝身份 |
| /pages/profile/profile | 个人中心 | 用户资料管理 |
| /pages/square/square | 广场首页 | 主页面 |
| /pages/exhibition/exhibition | 展馆页 | 展馆/展位管理 |
| /pages/castlove/success | 铸造成功 | 铸造成功展示 |
### 5.2 主要组件
| 组件 | 说明 |
|------|------|
| SquareContent | 广场主内容区(切换广场/好友/星册/铸爱/装扮) |
| FriendsContent | 好友列表与社交功能 |
| StarbookContent | 我的藏品/星册 |
| CastloveContent | 铸造功能 |
| DressupContent | 装扮功能 |
| NftCard | 藏品卡片 |
| NftDetailModal | 藏品详情弹窗 |
| Avatar | 头像组件 |
| Header | 顶部导航栏 |
| BottomNav | 底部导航栏 |
| TaskModal | 任务弹窗 |
---
## 6. 业务规则
### 6.1 粉丝身份隔离
- 每个用户可绑定最多2个粉丝身份
- 切换身份后,藏品、展馆、好友等数据按身份隔离
- Token中包含user_id和star_id
### 6.2 展馆规则
- 初始展位: 3个
- 可解锁/购买更多展位
- 可展示藏品到他人展馆空位
- 抢展位: 4小时后自动下架
### 6.3 好友规则
- 需双向确认才能成为好友
- 默认上限: 50个
- 可随等级解锁更多
### 6.4 铸造规则
- 支持平台素材或用户上传
- 需审核和AI生成封面
- 铸造后生成链上确权
---
## 7. 安全要求
### 7.1 认证
- JWT Token认证
- Token有效期: 7天
- 从Header提取: Authorization: Bearer \<token\>
### 7.2 响应格式
```json
{
"code": 200,
"message": "ok",
"data": {}
}
```
### 7.3 分页格式
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [],
"page": 1,
"page_size": 20,
"total": 100
}
}
```
---
## 8. 外部依赖
| 服务 | 说明 |
|------|------|
| 阿里云OSS | 藏品图片存储 |
| PostgreSQL | 数据持久化 |
| (区块链) | 数字藏品上链(待实现) |
---
## 9. 待实现功能
根据代码分析,以下功能标记为待实现或部分实现:
1. **广场推荐** - 广场小屋列表、TOP藏品展板
2. **任务系统** - 完整任务列表和奖励领取
3. **新手引导** - 引导流程和状态管理
4. **踢走占位** - 被占位者踢走占领者
5. **解锁展位** - 等级解锁或货币购买
6. **系统推荐好友** - 推荐好友列表
---
## 10. 附录
### 10.1 错误码定义
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 401 | Token失效/未认证 |
| 400 | 请求参数错误 |
| 500 | 服务器内部错误 |
### 10.2 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| V1.0 | 2026-03-11 | 初始版本(基于代码分析) |
---
*本文档基于代码分析自动生成,如需更新请参考实际实现。*

118
docs/Tasks-Rankings.md Normal file
View File

@ -0,0 +1,118 @@
# 排行榜功能后端开发任务
## 任务概览
| 项目 | 内容 |
|------|------|
| 功能 | 热度排行榜、自制排行榜 |
| 状态 | 已完成 |
| 开始日期 | 2026-03-12 |
---
## 任务清单
### Phase 1: 数据模型准备
| 序号 | 任务 | 服务 | 状态 |
|------|------|------|------|
| 1.1 | 在 Asset 表新增 `is_original` 字段 | AssetService | ✅ 已完成 |
| 1.2 | 编写数据库迁移脚本 | AssetService | ✅ 已完成 |
### Phase 2: Proto 定义
| 序号 | 任务 | 状态 |
|------|------|------|
| 2.1 | 新增 ranking.proto 定义排行榜接口 | ✅ 已完成 |
| 2.2 | 编译 proto 生成 Go 代码 | ✅ 已完成 |
### Phase 3: 排行榜逻辑实现
| 序号 | 任务 | 服务 | 状态 |
|------|------|------|------|
| 3.1 | 实现热度排行榜查询逻辑 (displaying) | AssetService | ✅ 已完成 |
| 3.2 | 实现热度排行榜查询逻辑 (month) | AssetService | ✅ 已完成 |
| 3.3 | 实现热度排行榜查询逻辑 (total) | AssetService | ✅ 已完成 |
| 3.4 | 实现自制排行榜查询逻辑 | AssetService | ✅ 已完成 |
| 3.5 | 实现"我的排名"查询逻辑 | AssetService | ✅ 已完成 |
### Phase 4: Gateway 接口
| 序号 | 任务 | 状态 |
|------|------|------|
| 4.1 | 新增排行榜路由配置 | ✅ 已完成 |
| 4.2 | 实现排行榜 Controller | ✅ 已完成 |
| 4.3 | 添加 Swagger 文档 | ✅ 已完成 |
### Phase 5: 测试与优化
| 序号 | 任务 | 状态 |
|------|------|------|
| 5.1 | 单元测试 | ✅ 已完成 |
| 5.2 | API 联调测试 | ✅ 已完成 |
| 5.3 | 性能优化 (Redis 缓存) | ⬜ 待开始 |
---
## 新增文件清单
| 文件 | 说明 |
|------|------|
| `backend/proto/ranking.proto` | Proto 定义 |
| `backend/scripts/add_is_original_column.sql` | 数据库迁移脚本 |
| `backend/services/assetService/repository/ranking_repository.go` | 排行榜 Repository |
| `backend/services/assetService/repository/ranking_repository_test.go` | 排行榜单元测试 |
| `backend/services/assetService/service/ranking_service.go` | 排行榜 Service |
| `backend/services/assetService/provider/ranking_provider.go` | 排行榜 Provider |
| `backend/services/assetService/main.go` | 更新:注册 Ranking Service |
| `backend/gateway/controller/ranking_controller.go` | 排行榜 Controller |
| `backend/gateway/router/router.go` | 更新:添加排行榜路由 |
---
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/rankings/hot` | 获取热度排行榜 |
| GET | `/api/v1/rankings/original` | 获取自制排行榜 |
### API 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| dimension | string | 否 | 统计维度: displaying(展示中), month(本月), total(全部),默认 total |
| star_id | int64 | 否 | 粉丝身份ID不传则使用当前身份 |
| page | int | 否 | 页码,默认 1 |
| page_size | int | 否 | 每页数量,默认 10 |
---
## 进度记录
| 日期 | 完成任务 | 备注 |
|------|----------|------|
| 2026-03-12 | Phase 1 数据模型 | is_original 字段 + 迁移脚本 |
| 2026-03-12 | Phase 2 Proto | ranking.proto + 编译 |
| 2026-03-12 | Phase 3 逻辑 | Repository + Service + Provider |
| 2026-03-12 | Phase 4 Gateway | Controller + 路由配置 |
| 2026-03-12 | Phase 5.1 单元测试 | ranking_repository_test.go |
| 2026-03-12 | Phase 5.2 API 联调 | 全部 API 测试通过 |
---
## 技术依赖
- AssetService (20003) - 藏品和点赞数据
- Exhibition 表 - 展示中藏品判定
- AssetLike 表 - 本月点赞统计
- Redis - 排行榜缓存(待实现)
---
## 注意事项
1. 排行榜按粉丝身份 (star_id) 隔离
2. "我的排名"返回用户点赞最高的藏品
3. 取消点赞不实时扣除点赞数
4. 分页默认 10 条Top N 默认 100

View File

@ -0,0 +1,252 @@
<mxGraphModel dx="1422" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" pageWidth="1400" pageHeight="1000" math="0" shadow="0" defaultFontFamily="Noto Sans JP" defaultFontSize="14">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Title -->
<mxCell id="title" value="TopFans Backend Microservice Architecture" style="text;html=1;fontSize=24;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="400" width="600" y="20" height="40" as="geometry" />
</mxCell>
<!-- ==================== Gateway Layer ==================== -->
<mxCell id="gateway_box" value="" style="rounded=1;strokeWidth=3;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="40" y="80" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="gateway_label" value="API Gateway Layer" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="100" y="90" width="160" height="30" as="geometry" />
</mxCell>
<mxCell id="gateway" value="Gin HTTP Server" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="60" y="130" width="140" height="50" as="geometry" />
</mxCell>
<mxCell id="gateway_port" value="Port: 8080" style="text;html=1;fontSize=14;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="60" y="190" width="140" height="25" as="geometry" />
</mxCell>
<mxCell id="gateway_func" value="• HTTP Request/Response&#xa;• JWT Authentication&#xa;• Request Routing&#xa;• Swagger Docs" style="text;html=1;align=left;verticalAlign=top;spacingLeft=10;fontSize=12;fontFamily=Noto Sans JP;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="210" y="130" width="100" height="130" as="geometry" />
</mxCell>
<!-- ==================== Services Layer ==================== -->
<mxCell id="services_box" value="" style="rounded=1;strokeWidth=3;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="380" y="80" width="920" height="200" as="geometry" />
</mxCell>
<mxCell id="services_label" value="Microservices Layer (Dubbo-go)" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="600" y="90" width="280" height="30" as="geometry" />
</mxCell>
<!-- User Service -->
<mxCell id="user_service" value="User Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="400" y="130" width="130" height="60" as="geometry" />
</mxCell>
<mxCell id="user_port" value=":20000" style="text;html=1;fontSize=12;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="400" y="195" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="user_func" value="• Register/Login&#xa;• User Info&#xa;• Fan Identity&#xa;• Password" style="text;html=1;align=left;verticalAlign=top;spacingLeft=5;fontSize=11;fontFamily=Noto Sans JP;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="540" y="135" width="90" height="70" as="geometry" />
</mxCell>
<!-- Gallery Service -->
<mxCell id="gallery_service" value="Gallery Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="650" y="130" width="130" height="60" as="geometry" />
</mxCell>
<mxCell id="gallery_port" value=":20001" style="text;html=1;fontSize=12;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="650" y="195" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="gallery_func" value="• My Gallery&#xa;• Booth Slots&#xa;• Exhibitions&#xa;• Visit Friend" style="text;html=1;align=left;verticalAlign=top;spacingLeft=5;fontSize=11;fontFamily=Noto Sans JP;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="790" y="135" width="100" height="70" as="geometry" />
</mxCell>
<!-- Social Service -->
<mxCell id="social_service" value="Social Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="910" y="130" width="130" height="60" as="geometry" />
</mxCell>
<mxCell id="social_port" value=":20002" style="text;html=1;fontSize=12;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="910" y="195" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="social_func" value="• Friends&#xa;• Friend Request&#xa;• User Search&#xa;• Likes" style="text;html=1;align=left;verticalAlign=top;spacingLeft=5;fontSize=11;fontFamily=Noto Sans JP;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="1050" y="135" width="90" height="70" as="geometry" />
</mxCell>
<!-- Asset Service -->
<mxCell id="asset_service" value="Asset Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="1160" y="130" width="130" height="60" as="geometry" />
</mxCell>
<mxCell id="asset_port" value=":20003" style="text;html=1;fontSize=12;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="1160" y="195" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="asset_func" value="• Minting&#xa;• Assets&#xa;• OSS Upload&#xa;• Likes" style="text;html=1;align=left;verticalAlign=top;spacingLeft=5;fontSize=11;fontFamily=Noto Sans JP;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="1020" y="215" width="90" height="60" as="geometry" />
</mxCell>
<!-- ==================== Common Layer ==================== -->
<mxCell id="common_box" value="" style="rounded=1;strokeWidth=3;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="380" y="320" width="920" height="140" as="geometry" />
</mxCell>
<mxCell id="common_label" value="Common Layer" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="620" y="330" width="140" height="30" as="geometry" />
</mxCell>
<!-- Database -->
<mxCell id="database" value="PostgreSQL" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=10;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="420" y="360" width="100" height="80" as="geometry" />
</mxCell>
<!-- JWT -->
<mxCell id="jwt" value="JWT Auth" style="shape=process;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="560" y="375" width="90" height="50" as="geometry" />
</mxCell>
<!-- Logger -->
<mxCell id="logger" value="Uber Zap&#xa;Logger" style="shape=process;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="690" y="375" width="90" height="50" as="geometry" />
</mxCell>
<!-- GORM -->
<mxCell id="gorm" value="GORM&#xa;ORM" style="shape=process;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="820" y="375" width="90" height="50" as="geometry" />
</mxCell>
<!-- Dubbo -->
<mxCell id="dubbo" value="Dubbo Triple&#xa;Protocol" style="shape=process;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="950" y="375" width="100" height="50" as="geometry" />
</mxCell>
<!-- Swagger -->
<mxCell id="swagger" value="Swagger&#xa;API Docs" style="shape=process;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="1090" y="375" width="90" height="50" as="geometry" />
</mxCell>
<!-- ==================== Data Models ==================== -->
<mxCell id="models_box" value="" style="rounded=1;strokeWidth=3;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="40" y="320" width="280" height="140" as="geometry" />
</mxCell>
<mxCell id="models_label" value="Data Models" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="110" y="330" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="model_user" value="User" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="60" y="370" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="model_fan" value="FanProfile" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="150" y="370" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="model_star" value="Star" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="240" y="370" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="model_booth" value="BoothSlot" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="60" y="410" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="model_exhibition" value="Exhibition" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="150" y="410" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="model_friend" value="Friendship" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontFamily=Noto Sans JP;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="240" y="410" width="60" height="30" as="geometry" />
</mxCell>
<!-- ==================== External Clients ==================== -->
<mxCell id="client_box" value="" style="rounded=1;strokeWidth=3;fillColor=#e6e6e6;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="40" y="500" width="280" height="100" as="geometry" />
</mxCell>
<mxCell id="client_label" value="External Clients" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="100" y="510" width="160" height="30" as="geometry" />
</mxCell>
<mxCell id="mobile_client" value="Mobile App" style="shape=mobile;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="60" y="550" width="80" height="40" as="geometry" />
</mxCell>
<mxCell id="web_client" value="Web App" style="shape=rx;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="160" y="550" width="80" height="40" as="geometry" />
</mxCell>
<!-- ==================== OSS ==================== -->
<mxCell id="oss_box" value="" style="rounded=1;strokeWidth=3;fillColor=#e6e6e6;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="380" y="500" width="200" height="100" as="geometry" />
</mxCell>
<mxCell id="oss_label" value="External Services" style="text;html=1;fontSize=18;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="420" y="510" width="160" height="30" as="geometry" />
</mxCell>
<mxCell id="oss" value="OSS (Aliyun)" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=10;fillColor=#dae8fc;strokeColor=#6c8ebf;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="420" y="545" width="100" height="45" as="geometry" />
</mxCell>
<!-- ==================== Arrows ==================== -->
<!-- Client to Gateway -->
<mxCell id="arrow1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#333333;" edge="1" parent="1" source="mobile_client" target="gateway">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="arrow1_label" value="HTTP" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="170" y="340" width="40" height="20" as="geometry" />
</mxCell>
<!-- Gateway to Services -->
<mxCell id="arrow2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#333333;dashed=1;" edge="1" parent="1" source="gateway" target="user_service">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="arrow2_label" value="Dubbo RPC" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="270" y="115" width="80" height="20" as="geometry" />
</mxCell>
<!-- Services to Common Layer -->
<mxCell id="arrow3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#333333;" edge="1" parent="1" source="user_service" target="database">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="arrow3_label" value="SQL" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="350" y="300" width="30" height="20" as="geometry" />
</mxCell>
<!-- Service to OSS -->
<mxCell id="arrow4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#333333;dashed=1;" edge="1" parent="1" source="asset_service" target="oss">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="arrow4_label" value="Upload" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="580" y="520" width="50" height="20" as="geometry" />
</mxCell>
<!-- Service Communication Arrows -->
<mxCell id="arrow5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#9673a6;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="530" y="160" as="sourcePoint"/>
<mxPoint x="650" y="160" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="arrow5_label" value="RPC" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=10;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="575" y="140" width="30" height="20" as="geometry" />
</mxCell>
<mxCell id="arrow6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#9673a6;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="780" y="160" as="sourcePoint"/>
<mxPoint x="910" y="160" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="arrow6_label" value="RPC" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=10;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="830" y="140" width="30" height="20" as="geometry" />
</mxCell>
<mxCell id="arrow7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;strokeColor=#9673a6;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1040" y="160" as="sourcePoint"/>
<mxPoint x="1160" y="160" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="arrow7_label" value="RPC" style="text;html=1;align=center;verticalAlign=bottom;spacingLeft=-10;fontSize=10;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="1090" y="140" width="30" height="20" as="geometry" />
</mxCell>
<!-- Legend -->
<mxCell id="legend_box" value="" style="rounded=1;strokeWidth=1;fillColor=#ffffff;strokeColor=#999999;dashed=1;" vertex="1" parent="1">
<mxGeometry x="650" y="500" width="180" height="90" as="geometry" />
</mxCell>
<mxCell id="legend_title" value="Legend" style="text;html=1;fontSize=14;fontFamily=Noto Sans JP;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="710" y="510" width="60" height="20" as="geometry" />
</mxCell>
<mxCell id="legend1" value="── HTTP/RPC" style="text;html=1;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="660" y="535" width="80" height="20" as="geometry" />
</mxCell>
<mxCell id="legend2" value="-- - External" style="text;html=1;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="660" y="555" width="80" height="20" as="geometry" />
</mxCell>
<mxCell id="legend3" value="─── Inter-service" style="text;html=1;fontSize=11;fontFamily=Noto Sans JP;" vertex="1" parent="1">
<mxGeometry x="740" y="535" width="80" height="20" as="geometry" />
</mxCell>
</root>
</mxGraphModel>

View File

@ -0,0 +1,298 @@
# 新手引导修改实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修改新手引导流程:点击"去做"后直接从第一项引导开始,存储步骤进度,高亮区域支持点击触发动作
**Architecture:** 修改 GuideStartModal 的 start 事件触发逻辑,添加步骤进度存储,支持高亮区域点击事件
**Tech Stack:** Vue 3 / Vuex / uni-app
---
### Task 1: 修改 GuideStartModal 点击"去做"后直接开始引导
**Files:**
- Modify: `frontend/pages/square/square.vue:751-754`
- Modify: `frontend/components/GuideStartModal.vue:39-45`
- [ ] **Step 1: 修改 square.vue 的 handleGuideStart 方法**
```javascript
// 原本代码
const handleGuideStart = () => {
showGuideStartModal.value = false;
showGuideListModal.value = true;
};
// 修改为:直接开始第一个未完成的引导
const handleGuideStart = () => {
showGuideStartModal.value = false;
// 获取所有引导keys按顺序获取第一个未完成的
const keys = getAllGuideKeys();
const firstUndoneKey = keys.find(key => !isGuideDone(key));
if (firstUndoneKey) {
store.dispatch('guide/initGuide', firstUndoneKey);
}
};
```
需要 import
```javascript
import { getAllGuideKeys, isGuideDone } from '@/utils/guideConfig.js';
```
- [ ] **Step 2: 验证修改**
运行项目,点击"去做"按钮,验证是否直接进入第一个引导而不是显示列表弹窗。
- [ ] **Step 3: 提交**
---
### Task 2: 添加引导步骤进度存储功能
**Files:**
- Modify: `frontend/utils/guideConfig.js:252-315`
- Modify: `frontend/store/modules/guide.js:60-74`
- [ ] **Step 1: 在 guideConfig.js 添加步骤进度存储函数**
```javascript
/**
* 获取引导的当前步骤
* @param {string} key 引导key
* @returns {number} 当前步骤索引默认0
*/
export function getGuideCurrentStep(key) {
return uni.getStorageSync(`guide_step_${key}`) || 0
}
/**
* 设置引导的当前步骤
* @param {string} key 引导key
* @param {number} step 步骤索引
*/
export function setGuideCurrentStep(key, step) {
uni.setStorageSync(`guide_step_${key}`, step)
}
/**
* 获取下一个未完成的引导key
* @returns {string|null} 引导key或null
*/
export function getNextUndoneGuideKey() {
const keys = getAllGuideKeys()
return keys.find(key => !isGuideDone(key)) || null
}
```
- [ ] **Step 2: 修改 store/modules/guide.js 的 END_GUIDE mutation**
```javascript
// 在 mutations 中修改
END_GUIDE(state) {
if (state.currentGuide) {
const key = state.currentGuide.key
markGuideAsShown(key)
markGuideDone(key)
// 重置步骤进度
setGuideCurrentStep(key, 0)
}
state.currentGuide = null
state.currentStep = 0
state.isActive = false
state.isNavigating = false
},
```
- [ ] **Step 3: 修改 NEXT_STEP mutation 添加步骤存储**
```javascript
NEXT_STEP(state) {
if (state.currentGuide && state.currentStep < state.currentGuide.steps.length - 1) {
state.currentStep++
// 存储当前步骤
setGuideCurrentStep(state.currentGuide.key, state.currentStep)
}
},
```
- [ ] **Step 4: 修改 START_GUIDE mutation 恢复步骤进度**
```javascript
START_GUIDE(state, guideConfig) {
state.currentGuide = guideConfig
// 恢复之前保存的步骤进度
state.currentStep = getGuideCurrentStep(guideConfig.key) || 0
state.isActive = true
state.isNavigating = false
},
```
- [ ] **Step 5: 提交**
---
### Task 3: 高亮区域支持点击触发动作
**Files:**
- Modify: `frontend/utils/guideConfig.js:37-157` (steps 配置)
- Modify: `frontend/components/GuideOverlay.vue:245-248`
- [ ] **Step 1: 扩展 step 配置结构**
在 guideConfig.js 的 steps 配置中添加 action 字段:
```javascript
// action 支持的类型:
// 1. { type: 'navigate', url: '/pages/xxx/xxx' } - 路由跳转
// 2. { type: 'component', name: 'xxx' } - 打开组件
// 3. { type: 'function', handler: 'xxx' } - 执行函数
// 示例配置:
{
target: '.banner-carousel',
content: '这里是轮播图,点击可以查看排行榜和活动',
center: true,
buttons: ['next'],
action: {
type: 'navigate',
url: '/pages/rank/rank'
}
}
```
- [ ] **Step 2: 修改 GuideOverlay 的 handleHighlightClick 方法**
```javascript
// 点击高亮区域
function handleHighlightClick() {
const stepConfigVal = stepConfig.value
if (stepConfigVal && stepConfigVal.action) {
const { action } = stepConfigVal
if (action.type === 'navigate' && action.url) {
// 路由跳转
uni.navigateTo({
url: action.url,
fail: (err) => {
console.error('[Guide] 页面跳转失败:', err)
}
})
} else if (action.type === 'component' && action.name) {
// 打开组件(通过事件通知父组件)
store.dispatch('guide/openComponent', action.name)
} else if (action.type === 'function' && action.handler) {
// 执行函数
store.dispatch('guide/executeFunction', action.handler)
}
}
}
```
- [ ] **Step 3: 在 store 中添加处理 action 的 actions**
```javascript
// 在 store/modules/guide.js 中添加
actions: {
// ... existing actions ...
/**
* 打开组件
*/
openComponent({ commit }, componentName) {
console.log(`[Guide] 打开组件: ${componentName}`)
commit('OPEN_COMPONENT', componentName)
},
/**
* 执行函数
*/
executeFunction({ commit }, handler) {
console.log(`[Guide] 执行函数: ${handler}`)
commit('EXECUTE_FUNCTION', handler)
}
}
// 添加对应的 mutations
mutations: {
// ... existing mutations ...
OPEN_COMPONENT(state, componentName) {
state.pendingAction = { type: 'component', name: componentName }
},
EXECUTE_FUNCTION(state, handler) {
state.pendingAction = { type: 'function', handler }
}
}
// 在 state 中添加
state: {
// ... existing state ...
pendingAction: null, // 待执行的行动
}
```
- [ ] **Step 4: 提交**
---
### Task 4: 验证个人中心引导列表弹窗不受影响
**Files:**
- Review: `frontend/pages/profile/profile.vue:914-936`
- [ ] **Step 1: 检查 profile.vue 中 handleDoGuide 方法**
当前代码:
```javascript
const handleDoGuide = (key) => {
showGuideListModal.value = false;
if (isFirstEntry) {
store.dispatch("guide/initGuide", key);
} else {
store.dispatch("guide/initGuide", key);
}
};
```
验证逻辑保持不变:用户从个人中心点击进入某个引导时,触发 initGuide 动作。这与 Task 1 的修改不冲突,因为:
- square.vue 的修改只影响新用户首次点击"去做"的行为
- profile.vue 中的 GuideListModal 保持原有逻辑
- [ ] **Step 2: 测试验证**
1. 从个人中心进入引导列表
2. 点击某个引导的"去做"按钮
3. 确认引导能正常启动,且列表弹窗行为不变
- [ ] **Step 3: 提交**
---
### Task 5: 端到端测试
- [ ] **Step 1: 测试完整流程**
1. 模拟新用户登录
2. 验证 GuideStartModal 显示
3. 点击"去做"
4. 验证直接进入第一个引导而不是显示列表
5. 完成引导步骤
6. 验证步骤进度被正确存储
7. 验证高亮区域点击能触发相应动作(配置了 action 的情况下)
- [ ] **Step 2: 测试个人中心入口**
1. 进入个人中心
2. 点击引导按钮
3. 验证 GuideListModal 正常显示
4. 选择一个引导执行
5. 验证行为与之前一致
- [ ] **Step 3: 提交**

BIN
docs/界面交互说明.pdf Normal file

Binary file not shown.

Binary file not shown.