最近在参与一个艺术展览的数字化呈现项目时,遇到了一个有趣的挑战:如何将一场名为“即兴生活家•Doris的环球感官艺术实验”的、充满动态与交互性的展览,通过技术手段进行线上还原与数据化管理。这不仅仅是搭建一个简单的线上展厅,更涉及到展品信息管理、用户交互数据收集、多感官体验(如视觉、听觉)的数字化模拟,以及背后复杂的权限与内容管理。

本文将从一个后端开发者的视角,分享如何利用主流技术栈(如Spring Boot、Vue.js、MySQL/PostgreSQL)来构建一个支撑此类艺术展览的数字化管理平台。我们将从需求分析、数据库设计、后端API开发、到前端展示与数据可视化,一步步拆解实现过程。无论你是想学习全栈项目开发,还是对文化科技融合项目感兴趣,都能从中获得一套可复用的实战方案。

1. 项目背景与核心需求分析

“即兴生活家•Doris的环球感官艺术实验”这类展览,其核心在于“即兴”与“感官”。这意味着展品可能不是静态的,其呈现内容(如视频、音频、图文描述)会根据时间、观众互动甚至环境数据发生变化。同时,“感官艺术”强调视觉、听觉甚至触觉(通过设备模拟)的融合体验。

因此,我们的数字化平台需要满足以下核心需求:

  1. 动态内容管理 :策展人需要能随时更新展品信息、替换媒体文件(图片、视频、音频)、调整展品在虚拟展厅中的位置与关联关系。
  2. 多感官媒体支持 :系统需能高效存储、管理和流式传输高清图片、视频及音频文件。
  3. 用户交互与数据收集 :记录用户在虚拟展厅中的浏览路径、在每件展品前的停留时长、互动操作(如点赞、评论、分享),并可能根据这些数据动态调整内容推荐。
  4. 虚拟展厅可视化 :需要一个前端界面,以2D平面图或3D空间的形式,直观展示展览布局,并允许用户点击展品进行深入探索。
  5. 权限与角色管理 :区分超级管理员、策展人(内容编辑)、普通观众等角色,控制其对后台管理功能和前端内容的访问权限。

基于以上需求,我们选择以下技术栈:

  • 后端 :Spring Boot 2.7.x(稳定,生态丰富)
  • 数据库 :PostgreSQL 14(对JSON、地理空间数据支持好,适合复杂展品属性) + Redis 7(缓存热点数据,如展厅布局、热门展品)
  • 文件存储 :MinIO(自建对象存储,兼容S3协议,用于存储图片、视频、音频)
  • 前端 :Vue 3 + TypeScript + Pinia + Element Plus
  • 部署 :Docker + Docker Compose(便于环境统一)

2. 环境准备与项目初始化

在开始编码前,请确保你的开发环境已就绪。

2.1 基础环境要求

  • 操作系统 :Windows 10/11, macOS, 或 Linux (Ubuntu 20.04+)
  • Java :JDK 11 或 17(推荐17,本文示例使用17)
  • Node.js :18.x 或 20.x LTS 版本
  • Maven :3.8+
  • Docker & Docker Compose :用于快速启动数据库、Redis、MinIO等服务。

2.2 使用 Docker Compose 启动基础设施

为了避免在本地安装多种服务,我们使用 docker-compose.yml 一键启动所有依赖。

在项目根目录创建 docker-compose.yml 文件:

version: '3.8'
services:
  postgres:
    image: postgres:14-alpine
    container_name: art-exhibition-db
    environment:
      POSTGRES_DB: exhibition_db
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: strongpassword123
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - exhibition-network

  redis:
    image: redis:7-alpine
    container_name: art-exhibition-redis
    ports:
      - "6379:6379"
    networks:
      - exhibition-network

  minio:
    image: minio/minio
    container_name: art-exhibition-minio
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123
    volumes:
      - minio_data:/data
    command: server /data --console-address ":9001"
    networks:
      - exhibition-network

volumes:
  postgres_data:
  minio_data:

networks:
  exhibition-network:
    driver: bridge

在终端中,进入该文件所在目录,运行:

docker-compose up -d

等待所有容器启动。你可以通过 docker ps 检查状态。

2.3 初始化 Spring Boot 后端项目

使用 Spring Initializr 或 IDE 创建项目,依赖选择:

  • Spring Web
  • Spring Data JPA
  • PostgreSQL Driver
  • Lombok
  • Spring Boot DevTools
  • Validation

生成项目后,解压并用 IDE 打开。

2.4 初始化 Vue 3 前端项目

使用 Vite 快速创建 Vue 项目:

npm create vue@latest art-exhibition-frontend

在创建过程中,选择以下特性:

  • TypeScript
  • Vue Router
  • Pinia
  • Element Plus

创建完成后,进入项目目录并安装依赖:

cd art-exhibition-frontend
npm install
npm run dev

至此,基础环境与项目骨架已搭建完毕。

3. 数据库设计与核心实体建模

根据展览需求,我们设计以下几个核心实体。

3.1 实体关系分析

  1. 展览 (Exhibition) :一次展览活动的元信息,如标题、描述、开始/结束时间、封面图。
  2. 展厅/区域 (Zone) :一个展览可能包含多个虚拟区域(如“视觉区”、“听觉区”)。
  3. 展品 (Exhibit) :核心实体,属于某个展厅。包含动态内容属性。
  4. 媒体资源 (MediaAsset) :独立的媒体文件(图片、视频、音频),与展品多对多关联(一个展品可有多个媒体,一个媒体可用于多个展品)。
  5. 用户行为记录 (UserActionLog) :记录用户的浏览、互动行为。

3.2 SQL 表结构定义

src/main/resources/schema.sql 中定义初始表结构(也可由JPA自动生成,但生产环境建议控制DDL)。

-- 展览表
CREATE TABLE exhibition (
    id BIGSERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    cover_image_url VARCHAR(512),
    start_time TIMESTAMP,
    end_time TIMESTAMP,
    is_active BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 展厅区域表
CREATE TABLE zone (
    id BIGSERIAL PRIMARY KEY,
    exhibition_id BIGINT NOT NULL REFERENCES exhibition(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    floor_plan_image_url VARCHAR(512), -- 2D平面图
    coordinate_info JSONB, -- 存储区域在虚拟空间中的坐标或3D信息
    sort_order INT DEFAULT 0
);

-- 展品表
CREATE TABLE exhibit (
    id BIGSERIAL PRIMARY KEY,
    zone_id BIGINT NOT NULL REFERENCES zone(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    artist VARCHAR(255),
    description TEXT,
    content JSONB NOT NULL DEFAULT '{}', -- 动态内容,如不同时间的描述、关联的媒体ID列表
    position_info JSONB, -- 在展厅中的位置信息 {x: 10, y: 20}
    is_interactive BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 媒体资源表
CREATE TABLE media_asset (
    id BIGSERIAL PRIMARY KEY,
    original_filename VARCHAR(255) NOT NULL,
    storage_key VARCHAR(512) NOT NULL UNIQUE, -- 在MinIO中的对象键
    file_url VARCHAR(512) NOT NULL, -- 可访问的URL
    media_type VARCHAR(50) NOT NULL, -- 'IMAGE', 'VIDEO', 'AUDIO'
    mime_type VARCHAR(100),
    size_bytes BIGINT,
    uploader_id BIGINT, -- 关联用户表,此处简化
    uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 展品-媒体关联表 (多对多)
CREATE TABLE exhibit_media (
    exhibit_id BIGINT NOT NULL REFERENCES exhibit(id) ON DELETE CASCADE,
    media_id BIGINT NOT NULL REFERENCES media_asset(id) ON DELETE CASCADE,
    sort_order INT DEFAULT 0,
    PRIMARY KEY (exhibit_id, media_id)
);

-- 用户行为日志表 (简化版)
CREATE TABLE user_action_log (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(255), -- 匿名会话ID
    user_id BIGINT, -- 登录用户ID
    exhibit_id BIGINT REFERENCES exhibit(id),
    action_type VARCHAR(50) NOT NULL, -- 'VIEW', 'CLICK', 'LIKE', 'SHARE', 'COMMENT'
    action_detail JSONB, -- 如评论内容、分享平台
    duration_ms INTEGER, -- 停留时长(毫秒)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_log_exhibit ON user_action_log(exhibit_id);
CREATE INDEX idx_log_created ON user_action_log(created_at);

关键设计说明

  • JSONB字段 :PostgreSQL的 JSONB 类型非常适合存储动态、非结构化的展品 content position_info ,避免了频繁的表结构变更。
  • 媒体分离 :将媒体文件元数据独立存储,通过关联表与展品连接,提高了媒体资源的复用性和管理效率。
  • 行为日志 session_id 用于追踪未登录用户, action_detail 用JSONB存储可变的具体信息,使日志表结构保持稳定。

4. 后端核心功能实现

4.1 实体类与Repository

首先,创建JPA实体类,与数据库表对应。

// 文件:src/main/java/com/art/exhibition/entity/Exhibition.java
package com.art.exhibition.entity;

import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;

@Data
@Entity
@Table(name = "exhibition")
public class Exhibition {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    private String coverImageUrl;

    private LocalDateTime startTime;
    private LocalDateTime endTime;

    private Boolean isActive = false;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;
}
// 文件:src/main/java/com/art/exhibition/entity/Exhibit.java
package com.art.exhibition.entity;

import com.vladmihalcea.hibernate.type.json.JsonBinaryType;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.TypeDef;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;

@Data
@Entity
@Table(name = "exhibit")
@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class) // 定义JSONB类型映射
public class Exhibit {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private String artist;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Type(type = "jsonb")
    @Column(columnDefinition = "jsonb")
    private String content = "{}"; // 存储为JSON字符串,如 {"mediaIds": [1,2], "narrative": "..."}

    @Type(type = "jsonb")
    @Column(columnDefinition = "jsonb")
    private String positionInfo = "{}";

    private Boolean isInteractive = false;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "zone_id", nullable = false)
    private Zone zone;

    @ManyToMany
    @JoinTable(
        name = "exhibit_media",
        joinColumns = @JoinColumn(name = "exhibit_id"),
        inverseJoinColumns = @JoinColumn(name = "media_id")
    )
    private Set<MediaAsset> mediaAssets = new HashSet<>();

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;
}

类似地,创建 Zone MediaAsset 等实体类。然后创建对应的Spring Data JPA Repository接口。

// 文件:src/main/java/com/art/exhibition/repository/ExhibitRepository.java
package com.art.exhibition.repository;

import com.art.exhibition.entity.Exhibit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface ExhibitRepository extends JpaRepository<Exhibit, Long> {
    List<Exhibit> findByZoneIdOrderByCreatedAtDesc(Long zoneId);

    @Query("SELECT e FROM Exhibit e WHERE e.zone.exhibition.id = :exhibitionId")
    List<Exhibit> findByExhibitionId(@Param("exhibitionId") Long exhibitionId);
}

4.2 文件上传服务(集成MinIO)

我们需要一个服务来处理图片、视频、音频的上传,并返回可访问的URL。

首先,添加MinIO Java SDK依赖到 pom.xml

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.2</version>
</dependency>

然后,配置MinIO连接属性并创建服务类。

// 文件:src/main/java/com/art/exhibition/config/MinioConfig.java
package com.art.exhibition.config;

import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MinioConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}
# 文件:src/main/resources/application.yml
minio:
  endpoint: http://localhost:9000
  accessKey: minioadmin
  secretKey: minioadmin123
  bucket: art-exhibition-media
// 文件:src/main/java/com/art/exhibition/service/FileStorageService.java
package com.art.exhibition.service;

import com.art.exhibition.entity.MediaAsset;
import com.art.exhibition.repository.MediaAssetRepository;
import io.minio.*;
import io.minio.errors.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class FileStorageService {
    private final MinioClient minioClient;
    private final MediaAssetRepository mediaAssetRepository;

    @Value("${minio.bucket}")
    private String bucketName;

    /**
     * 上传文件到MinIO并保存元数据到数据库
     */
    public MediaAsset uploadFile(MultipartFile file, Long uploaderId) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        // 1. 确保存储桶存在
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        }

        // 2. 生成唯一文件名(避免覆盖)
        String originalFilename = file.getOriginalFilename();
        String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));
        String storageKey = UUID.randomUUID() + fileExtension;

        // 3. 上传到MinIO
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(storageKey)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build()
        );

        // 4. 构建可访问的URL (生产环境应配置CDN或域名)
        String fileUrl = String.format("%s/%s/%s", System.getProperty("minio.endpoint", "http://localhost:9000"), bucketName, storageKey);

        // 5. 保存元数据到数据库
        MediaAsset mediaAsset = new MediaAsset();
        mediaAsset.setOriginalFilename(originalFilename);
        mediaAsset.setStorageKey(storageKey);
        mediaAsset.setFileUrl(fileUrl);
        mediaAsset.setMediaType(determineMediaType(file.getContentType()));
        mediaAsset.setMimeType(file.getContentType());
        mediaAsset.setSizeBytes(file.getSize());
        mediaAsset.setUploaderId(uploaderId);
        mediaAsset.setUploadedAt(LocalDateTime.now());

        return mediaAssetRepository.save(mediaAsset);
    }

    private String determineMediaType(String mimeType) {
        if (mimeType.startsWith("image/")) {
            return "IMAGE";
        } else if (mimeType.startsWith("video/")) {
            return "VIDEO";
        } else if (mimeType.startsWith("audio/")) {
            return "AUDIO";
        } else {
            return "OTHER";
        }
    }
}

4.3 核心业务API控制器

创建RESTful API,供前端调用。

// 文件:src/main/java/com/art/exhibition/controller/ExhibitController.java
package com.art.exhibition.controller;

import com.art.exhibition.dto.ExhibitDetailDTO;
import com.art.exhibition.dto.ExhibitSummaryDTO;
import com.art.exhibition.service.ExhibitService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/exhibits")
@RequiredArgsConstructor
public class ExhibitController {
    private final ExhibitService exhibitService;

    // 获取某个展厅下的所有展品摘要(用于展厅页面列表)
    @GetMapping("/zone/{zoneId}")
    public ResponseEntity<List<ExhibitSummaryDTO>> getExhibitsByZone(@PathVariable Long zoneId) {
        List<ExhibitSummaryDTO> exhibits = exhibitService.getExhibitsByZone(zoneId);
        return ResponseEntity.ok(exhibits);
    }

    // 获取单个展品详情(包含关联的媒体资源)
    @GetMapping("/{exhibitId}")
    public ResponseEntity<ExhibitDetailDTO> getExhibitDetail(@PathVariable Long exhibitId) {
        ExhibitDetailDTO detail = exhibitService.getExhibitDetail(exhibitId);
        if (detail == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(detail);
    }

    // 记录用户浏览行为(如停留时长)
    @PostMapping("/{exhibitId}/view")
    public ResponseEntity<Void> logViewAction(@PathVariable Long exhibitId,
                                              @RequestParam(required = false) String sessionId,
                                              @RequestParam(required = false) Long durationMs) {
        exhibitService.logUserAction(exhibitId, sessionId, "VIEW", null, durationMs);
        return ResponseEntity.ok().build();
    }
}

对应的Service层负责业务逻辑组装、DTO转换和行为日志记录。

5. 前端虚拟展厅实现

前端部分,我们使用Vue 3和Element Plus构建一个简单的2D平面展厅视图。

5.1 展厅平面图与展品定位

假设我们有一张展厅的平面图作为背景,展品通过 positionInfo 中的坐标进行绝对定位。

<!-- 文件:src/views/ExhibitionZoneView.vue -->
<template>
  <div class="zone-container">
    <h2>{{ zone.name }}</h2>
    <p>{{ zone.description }}</p>

    <!-- 展厅平面图背景 -->
    <div class="floor-plan-container" :style="{ backgroundImage: `url(${zone.floorPlanImageUrl})` }">
      <!-- 动态渲染展品位置 -->
      <div
        v-for="exhibit in exhibits"
        :key="exhibit.id"
        class="exhibit-marker"
        :style="{
          left: exhibit.positionInfo.x + 'px',
          top: exhibit.positionInfo.y + 'px'
        }"
        @click="onExhibitClick(exhibit)"
        @mouseenter="showTooltip(exhibit)"
        @mouseleave="hideTooltip"
      >
        <el-tooltip
          v-if="hoveredExhibitId === exhibit.id"
          effect="dark"
          :content="exhibit.title"
          placement="top"
        >
          <div class="marker-icon">📍</div>
        </el-tooltip>
        <div v-else class="marker-icon">📍</div>
      </div>
    </div>

    <!-- 展品详情模态框 -->
    <el-dialog
      v-model="detailDialogVisible"
      :title="selectedExhibit?.title"
      width="70%"
    >
      <div v-if="selectedExhibit">
        <h3>艺术家: {{ selectedExhibit.artist }}</h3>
        <p>{{ selectedExhibit.description }}</p>
        <!-- 媒体展示区 -->
        <div class="media-gallery">
          <div v-for="media in selectedExhibit.mediaAssets" :key="media.id" class="media-item">
            <img v-if="media.mediaType === 'IMAGE'" :src="media.fileUrl" :alt="media.originalFilename" />
            <video v-else-if="media.mediaType === 'VIDEO'" controls :src="media.fileUrl"></video>
            <audio v-else-if="media.mediaType === 'AUDIO'" controls :src="media.fileUrl"></audio>
          </div>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getExhibitsByZone } from '@/api/exhibit'
import type { ExhibitSummaryDTO } from '@/types/exhibit'

const route = useRoute()
const zoneId = route.params.zoneId as string

const zone = ref({
  id: zoneId,
  name: '感官实验区',
  description: '探索声音与视觉的即兴融合',
  floorPlanImageUrl: '/api/static/floor-plan-1.jpg' // 从后端获取
})

const exhibits = ref<ExhibitSummaryDTO[]>([])
const selectedExhibit = ref<any>(null)
const detailDialogVisible = ref(false)
const hoveredExhibitId = ref<number | null>(null)

// 获取展品列表
const loadExhibits = async () => {
  try {
    const response = await getExhibitsByZone(parseInt(zoneId))
    exhibits.value = response.data
    // 模拟记录页面浏览行为(发送到后端)
    // logZoneView(zoneId)
  } catch (error) {
    console.error('Failed to load exhibits:', error)
  }
}

const onExhibitClick = (exhibit: ExhibitSummaryDTO) => {
  selectedExhibit.value = exhibit
  detailDialogVisible.value = true
  // 记录点击行为
  // logExhibitClick(exhibit.id)
}

const showTooltip = (exhibit: ExhibitSummaryDTO) => {
  hoveredExhibitId.value = exhibit.id
}

const hideTooltip = () => {
  hoveredExhibitId.value = null
}

onMounted(() => {
  loadExhibits()
})
</script>

<style scoped>
.zone-container {
  padding: 20px;
}
.floor-plan-container {
  position: relative;
  width: 100%;
  height: 600px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  border: 1px solid #ccc;
  margin-top: 20px;
}
.exhibit-marker {
  position: absolute;
  cursor: pointer;
  transform: translate(-50%, -50%); /* 使图标中心对准坐标点 */
  z-index: 10;
}
.marker-icon {
  font-size: 24px;
  transition: transform 0.2s;
}
.marker-icon:hover {
  transform: scale(1.3);
}
.media-gallery {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}
.media-item img, .media-item video {
  max-width: 300px;
  max-height: 200px;
}
</style>

5.2 API调用封装

使用Axios进行HTTP请求封装。

// 文件:src/api/exhibit.ts
import axios from '@/utils/axios'
import type { ExhibitSummaryDTO, ExhibitDetailDTO } from '@/types/exhibit'

export function getExhibitsByZone(zoneId: number) {
  return axios.get<ExhibitSummaryDTO[]>(`/api/exhibits/zone/${zoneId}`)
}

export function getExhibitDetail(exhibitId: number) {
  return axios.get<ExhibitDetailDTO>(`/api/exhibits/${exhibitId}`)
}

export function logViewAction(exhibitId: number, sessionId?: string, durationMs?: number) {
  return axios.post(`/api/exhibits/${exhibitId}/view`, null, {
    params: { sessionId, durationMs }
  })
}

6. 数据可视化与后台管理

6.1 用户行为数据看板

使用ECharts或AntV G2,在后端提供一个数据聚合接口,前端展示热门展品、用户停留时长分布等。

后端示例接口:

@GetMapping("/api/analytics/popular-exhibits")
public ResponseEntity<List<PopularExhibitDTO>> getPopularExhibits(
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
    // 查询日志表,按展品分组统计VIEW和LIKE数量
    // 使用JPQL或原生SQL进行聚合查询
    List<PopularExhibitDTO> popularExhibits = analyticsService.getPopularExhibits(startDate, endDate);
    return ResponseEntity.ok(popularExhibits);
}

6.2 后台管理界面(CRUD)

使用Element Plus的表格、表单组件,为策展人提供对 Exhibition Zone Exhibit MediaAsset 的增删改查界面。关键点在于处理JSONB字段的编辑(可以使用JSON编辑器组件)和文件上传。

7. 部署与性能优化建议

7.1 使用Docker Compose部署

将前后端都容器化,编写生产环境的 docker-compose.prod.yml

version: '3.8'
services:
  backend:
    build: ./backend
    container_name: exhibition-backend
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DB_HOST=postgres
      - REDIS_HOST=redis
      - MINIO_ENDPOINT=http://minio:9000
    depends_on:
      - postgres
      - redis
      - minio
    networks:
      - exhibition-network

  frontend:
    build: ./frontend
    container_name: exhibition-frontend
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - exhibition-network

  postgres:
    # ... 生产环境建议使用卷持久化数据,并设置更强密码
  redis:
    # ...
  minio:
    # ...

networks:
  exhibition-network:
    driver: bridge

7.2 性能与安全优化

  1. 缓存策略 :展厅布局、展品列表等不常变的数据,使用Redis缓存,设置合理的过期时间。
  2. 媒体文件CDN :生产环境应将MinIO的访问域名替换为CDN地址,并设置防盗链。
  3. API限流与鉴权 :使用Spring Security + JWT对管理API进行保护,对公开API(如查看展品)可实施简单的IP限流,防止恶意刷接口。
  4. 数据库索引 :在 user_action_log exhibit_id created_at 上建立复合索引,加速查询。
  5. 前端资源优化 :图片、视频使用懒加载,大文件分片上传。

8. 常见问题与排查思路

在开发与部署过程中,你可能会遇到以下典型问题:

问题现象 可能原因 解决思路
前端无法加载图片/视频 1. MinIO服务未启动或网络不通。
2. MinIO存储桶策略未设置为公开读(或前端未携带有效Token)。
3. 文件URL拼接错误。
1. 检查 docker ps 确认MinIO容器状态,检查 application.yml 中的endpoint配置。
2. 在MinIO控制台为存储桶设置 public 读取策略,或实现一个后端接口代理文件下载并鉴权。
3. 调试后端 FileStorageService 中生成的 fileUrl 是否正确。
上传文件时报 InvalidKeyException 或连接拒绝 1. MinIO的Access Key/Secret Key配置错误。
2. MinIO服务地址端口错误。
1. 核对 application.yml 中的 minio.accessKey minio.secretKey ,与 docker-compose.yml 中设置的环境变量一致。
2. 确认MinIO的API端口(默认9000)是否被占用或防火墙阻止。
查询展品列表非常慢 1. 未对关联表(如 zone , media_assets )使用懒加载或不当的 FetchType
2. 数据量过大,未分页。
3. 缺少数据库索引。
1. 检查实体类关联注解(如 @ManyToOne(fetch = FetchType.LAZY) ),在Service层使用 @EntityGraph JOIN FETCH 明确加载所需关联。
2. API增加分页参数,使用Spring Data的 Pageable
3. 对 exhibit.zone_id 等外键字段建立索引。
JSONB字段插入或查询出错 1. 实体类中JSONB字段类型映射不正确。
2. 存入的JSON字符串格式错误。
1. 确保实体类使用了 @TypeDef @Type(type = "jsonb") 注解,并正确引入 hibernate-types 依赖。
2. 使用 Jackson ObjectMapper 或确保手动拼接的JSON字符串是有效的。
前端跨域(CORS)错误 后端未配置CORS或配置不正确。 在后端添加全局CORS配置类:
@Bean
public WebMvcConfigurer corsConfigurer() { ... } ,允许前端的域名、端口和方法。

9. 项目总结与扩展方向

通过以上步骤,我们完成了一个支持“即兴生活家•Doris的环球感官艺术实验”这类动态展览的数字化平台核心功能。从数据库设计上,我们利用PostgreSQL的JSONB字段灵活应对了展品内容的动态性;通过MinIO对象存储高效管理了多媒体资源;前后端分离的架构使得虚拟展厅的交互体验与后台管理得以并行开发。

核心掌握点

  1. 灵活的数据模型设计 :针对非结构化需求,合理使用JSONB字段。
  2. 文件上传与存储方案 :集成MinIO实现文件的可靠存储与访问。
  3. 用户行为追踪 :设计可扩展的日志表结构,为数据分析打下基础。
  4. 前后端协同开发 :清晰的API契约与TypeScript类型定义,提升开发效率。

下一步可以探索的扩展方向

  • 3D虚拟展厅 :使用Three.js或A-Frame构建Web 3D展厅,让用户体验更具沉浸感。
  • 实时互动 :集成WebSocket,实现多用户在线聊天、虚拟导览员讲解或实时投票等互动功能。
  • 个性化推荐 :基于用户行为日志,使用简单的协同过滤或基于内容的推荐算法,在首页推荐用户可能感兴趣的展品。
  • 数据深度分析 :将行为日志同步到数据仓库(如ClickHouse),使用BI工具(如Metabase)生成更复杂的参观热力图、用户画像分析报告。
  • 移动端适配 :将前端项目改造为PWA(渐进式Web应用)或使用Uni-app等框架打包成小程序,方便手机端访问。

艺术与技术的结合,关键在于用稳定可靠的技术架构,去承载和放大艺术创作的无限可能。这个项目提供了一个坚实的起点,你可以根据具体展览的需求,在此基础上不断迭代,打造出更富创意的线上艺术体验。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐