commit 0e6d0c3584fbb66d43ff36d9cc12585205e54a0b Author: zerosaturation Date: Tue Apr 7 22:28:50 2026 +0800 初始化项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2dba5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/frontend/node_modules +/frontend/unpackage/ +.DS_Store +frontend/static/.DS_Store +frontend/pages/.DS_Store +frontend/.hbuilderx/launch.json +.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2ad1c5 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# TopFans \ No newline at end of file diff --git a/docker/.env.local b/docker/.env.local new file mode 100644 index 0000000..a58ea73 --- /dev/null +++ b/docker/.env.local @@ -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 diff --git a/docker/.env.prod b/docker/.env.prod new file mode 100644 index 0000000..ed7dea8 --- /dev/null +++ b/docker/.env.prod @@ -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 diff --git a/docker/Dockerfile.services b/docker/Dockerfile.services new file mode 100644 index 0000000..081e85f --- /dev/null +++ b/docker/Dockerfile.services @@ -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"] diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 0000000..fc24e56 --- /dev/null +++ b/docker/build.sh @@ -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 使用配置文件 (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 diff --git a/docker/deploy.sh b/docker/deploy.sh new file mode 100755 index 0000000..f44c5c7 --- /dev/null +++ b/docker/deploy.sh @@ -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 指定服务器(覆盖配置文件) + --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 " + 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 "$@" diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml new file mode 100644 index 0000000..2909834 --- /dev/null +++ b/docker/docker-compose.local.yml @@ -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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..06b26c9 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -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: diff --git a/docker/init-db.sql b/docker/init-db.sql new file mode 100644 index 0000000..f153db0 --- /dev/null +++ b/docker/init-db.sql @@ -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 +-- =================================================================== diff --git a/docker/start.sh b/docker/start.sh new file mode 100755 index 0000000..43c1df9 --- /dev/null +++ b/docker/start.sh @@ -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 配置文件 (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 "" diff --git a/docs/API_CALLS_SUMMARY.md b/docs/API_CALLS_SUMMARY.md new file mode 100644 index 0000000..9cc4066 --- /dev/null +++ b/docs/API_CALLS_SUMMARY.md @@ -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 `。 +- **统一响应解析**: + - 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}` +- **参数**:目标用户 ID(UID) +- **调用位置**: + - `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`。 + diff --git a/docs/PRD-Rankings.md b/docs/PRD-Rankings.md new file mode 100644 index 0000000..4707773 --- /dev/null +++ b/docs/PRD-Rankings.md @@ -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 字段 | diff --git a/docs/PRD-activity.md b/docs/PRD-activity.md new file mode 100644 index 0000000..7057794 --- /dev/null +++ b/docs/PRD-activity.md @@ -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. **整体流程**: 完整流程测试 - 购买道具 -> 贡献点增加 -> 排名更新 diff --git a/docs/PRD-my-profile.md b/docs/PRD-my-profile.md new file mode 100644 index 0000000..a8bbf5f --- /dev/null +++ b/docs/PRD-my-profile.md @@ -0,0 +1,206 @@ +# 个人主页(My Profile)PRD + +## 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` - 藏品网格布局参考 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..02bf329 --- /dev/null +++ b/docs/PRD.md @@ -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 \ + +### 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 | 初始版本(基于代码分析) | + +--- + +*本文档基于代码分析自动生成,如需更新请参考实际实现。* diff --git a/docs/Tasks-Rankings.md b/docs/Tasks-Rankings.md new file mode 100644 index 0000000..43010f6 --- /dev/null +++ b/docs/Tasks-Rankings.md @@ -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 diff --git a/docs/backend-architecture.drawio b/docs/backend-architecture.drawio new file mode 100644 index 0000000..6e08224 --- /dev/null +++ b/docs/backend-architecture.drawio @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/superpowers/plans/2026-03-25-guide-modification.md b/docs/superpowers/plans/2026-03-25-guide-modification.md new file mode 100644 index 0000000..5a5e2f7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-guide-modification.md @@ -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: 提交** \ No newline at end of file diff --git a/docs/界面交互说明.pdf b/docs/界面交互说明.pdf new file mode 100644 index 0000000..f921974 Binary files /dev/null and b/docs/界面交互说明.pdf differ diff --git a/docs/需求规格说明书 (1).pdf b/docs/需求规格说明书 (1).pdf new file mode 100644 index 0000000..2b92b3c Binary files /dev/null and b/docs/需求规格说明书 (1).pdf differ