SQL Server 数据库设计与优化方案:旅游信息平台

SQL Server旅游平台数据库优化

SQL Server 数据库设计与优化方案:旅游信息平台

下面我将详细设计 SQL Server 数据库结构,并提供全面的查询优化策略,确保旅游信息平台数据的高效存储与检索。

1. 数据库架构设计

1.1 数据库总体设计

-- 创建数据库
CREATE DATABASE TravelPlatform;
GO

USE TravelPlatform;
GO

-- 设置数据库选项以优化性能
ALTER DATABASE TravelPlatform SET RECOVERY SIMPLE;
ALTER DATABASE TravelPlatform SET AUTO_UPDATE_STATISTICS ON;
ALTER DATABASE TravelPlatform SET AUTO_CREATE_STATISTICS ON;
ALTER DATABASE TravelPlatform SET READ_COMMITTED_SNAPSHOT ON;
GO

2. 核心表结构设计

2.1 用户相关表

-- 用户表
CREATE TABLE dbo.Users (
    UserID INT IDENTITY(1,1) PRIMARY KEY,
    Username NVARCHAR(80) NOT NULL,
    Email NVARCHAR(120) NOT NULL,
    PasswordHash NVARCHAR(255) NOT NULL,
    DisplayName NVARCHAR(100),
    AvatarURL NVARCHAR(500),
    Bio NVARCHAR(MAX),
    Role NVARCHAR(20) NOT NULL DEFAULT 'user',
    EmailVerified BIT NOT NULL DEFAULT 0,
    LastLogin DATETIME2,
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 约束
    CONSTRAINT CHK_Users_Role CHECK (Role IN ('user', 'admin', 'moderator')),
    CONSTRAINT UQ_Users_Username UNIQUE (Username),
    CONSTRAINT UQ_Users_Email UNIQUE (Email)
);
GO

-- 用户会话表(支持JWT令牌黑名单)
CREATE TABLE dbo.UserSessions (
    SessionID UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
    UserID INT NOT NULL,
    TokenHash NVARCHAR(512) NOT NULL,
    ExpiresAt DATETIME2 NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT FK_UserSessions_UserID FOREIGN KEY (UserID) REFERENCES dbo.Users(UserID)
);
GO

2.2 景点相关表

-- 景点分类表(字典表)
CREATE TABLE dbo.AttractionCategories (
    CategoryID INT IDENTITY(1,1) PRIMARY KEY,
    CategoryName NVARCHAR(50) NOT NULL,
    Description NVARCHAR(200),
    IsActive BIT NOT NULL DEFAULT 1,
    SortOrder INT NOT NULL DEFAULT 0,
    
    CONSTRAINT UQ_AttractionCategories_Name UNIQUE (CategoryName)
);
GO

-- 景点表
CREATE TABLE dbo.Attractions (
    AttractionID INT IDENTITY(1,1) PRIMARY KEY,
    Name NVARCHAR(200) NOT NULL,
    Description NVARCHAR(MAX) NOT NULL,
    Location NVARCHAR(300) NOT NULL,
    City NVARCHAR(100) NOT NULL,
    Country NVARCHAR(100) NOT NULL,
    CategoryID INT NOT NULL,
    PriceRange NVARCHAR(20) NULL, -- free, low, medium, high
    OpeningHours NVARCHAR(200),
    BestSeason NVARCHAR(100),
    ContactInfo NVARCHAR(200),
    Website NVARCHAR(300),
    Latitude DECIMAL(10, 8),
    Longitude DECIMAL(11, 8),
    AverageRating DECIMAL(3, 2) DEFAULT 0.00,
    ReviewCount INT DEFAULT 0,
    ViewCount INT DEFAULT 0,
    Featured BIT NOT NULL DEFAULT 0,
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 约束
    CONSTRAINT CHK_Attractions_PriceRange CHECK (PriceRange IN ('free', 'low', 'medium', 'high', NULL)),
    CONSTRAINT CHK_Attractions_Rating CHECK (AverageRating >= 0 AND AverageRating <= 5),
    
    -- 外键
    CONSTRAINT FK_Attractions_CategoryID FOREIGN KEY (CategoryID) REFERENCES dbo.AttractionCategories(CategoryID)
);
GO

-- 景点图片表
CREATE TABLE dbo.AttractionImages (
    ImageID INT IDENTITY(1,1) PRIMARY KEY,
    AttractionID INT NOT NULL,
    ImageURL NVARCHAR(500) NOT NULL,
    Caption NVARCHAR(200),
    IsPrimary BIT NOT NULL DEFAULT 0,
    UploadOrder INT NOT NULL DEFAULT 0,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT FK_AttractionImages_AttractionID FOREIGN KEY (AttractionID) REFERENCES dbo.Attractions(AttractionID)
);
GO

-- 景点评论表
CREATE TABLE dbo.Reviews (
    ReviewID INT IDENTITY(1,1) PRIMARY KEY,
    UserID INT NOT NULL,
    AttractionID INT NOT NULL,
    Rating INT NOT NULL,
    Title NVARCHAR(200),
    Content NVARCHAR(MAX),
    VisitDate DATE,
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 约束
    CONSTRAINT CHK_Reviews_Rating CHECK (Rating >= 1 AND Rating <= 5),
    
    -- 外键
    CONSTRAINT FK_Reviews_UserID FOREIGN KEY (UserID) REFERENCES dbo.Users(UserID),
    CONSTRAINT FK_Reviews_AttractionID FOREIGN KEY (AttractionID) REFERENCES dbo.Attractions(AttractionID),
    
    -- 唯一约束:一个用户对一个景点只能评论一次
    CONSTRAINT UQ_Reviews_UserAttraction UNIQUE (UserID, AttractionID)
);
GO

2.3 攻略相关表

-- 攻略标签表
CREATE TABLE dbo.GuideTags (
    TagID INT IDENTITY(1,1) PRIMARY KEY,
    TagName NVARCHAR(50) NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT UQ_GuideTags_TagName UNIQUE (TagName)
);
GO

-- 攻略表
CREATE TABLE dbo.Guides (
    GuideID INT IDENTITY(1,1) PRIMARY KEY,
    Title NVARCHAR(300) NOT NULL,
    Content NVARCHAR(MAX) NOT NULL,
    Excerpt NVARCHAR(500),
    AuthorID INT NOT NULL,
    AttractionID INT NOT NULL,
    TravelSeason NVARCHAR(100),
    BudgetRange NVARCHAR(50),
    DurationDays INT,
    DifficultyLevel NVARCHAR(20) NOT NULL DEFAULT 'medium',
    ViewCount INT DEFAULT 0,
    LikeCount INT DEFAULT 0,
    ShareCount INT DEFAULT 0,
    Featured BIT NOT NULL DEFAULT 0,
    Status NVARCHAR(20) NOT NULL DEFAULT 'published',
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 约束
    CONSTRAINT CHK_Guides_BudgetRange CHECK (BudgetRange IN ('low', 'medium', 'high', 'luxury', NULL)),
    CONSTRAINT CHK_Guides_DifficultyLevel CHECK (DifficultyLevel IN ('easy', 'medium', 'hard')),
    CONSTRAINT CHK_Guides_Status CHECK (Status IN ('draft', 'published', 'archived')),
    CONSTRAINT CHK_Guides_DurationDays CHECK (DurationDays > 0 AND DurationDays <= 365),
    
    -- 外键
    CONSTRAINT FK_Guides_AuthorID FOREIGN KEY (AuthorID) REFERENCES dbo.Users(UserID),
    CONSTRAINT FK_Guides_AttractionID FOREIGN KEY (AttractionID) REFERENCES dbo.Attractions(AttractionID)
);
GO

-- 攻略标签关联表
CREATE TABLE dbo.GuideTagAssociations (
    GuideID INT NOT NULL,
    TagID INT NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT PK_GuideTagAssociations PRIMARY KEY (GuideID, TagID),
    CONSTRAINT FK_GuideTagAssociations_GuideID FOREIGN KEY (GuideID) REFERENCES dbo.Guides(GuideID),
    CONSTRAINT FK_GuideTagAssociations_TagID FOREIGN KEY (TagID) REFERENCES dbo.GuideTags(TagID)
);
GO

-- 攻略图片表
CREATE TABLE dbo.GuideImages (
    ImageID INT IDENTITY(1,1) PRIMARY KEY,
    GuideID INT NOT NULL,
    ImageURL NVARCHAR(500) NOT NULL,
    Caption NVARCHAR(200),
    UploadOrder INT NOT NULL DEFAULT 0,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT FK_GuideImages_GuideID FOREIGN KEY (GuideID) REFERENCES dbo.Guides(GuideID)
);
GO

-- 攻略评论表
CREATE TABLE dbo.Comments (
    CommentID INT IDENTITY(1,1) PRIMARY KEY,
    UserID INT NOT NULL,
    GuideID INT NOT NULL,
    ParentCommentID INT NULL, -- 支持回复功能
    Content NVARCHAR(MAX) NOT NULL,
    LikeCount INT DEFAULT 0,
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 外键
    CONSTRAINT FK_Comments_UserID FOREIGN KEY (UserID) REFERENCES dbo.Users(UserID),
    CONSTRAINT FK_Comments_GuideID FOREIGN KEY (GuideID) REFERENCES dbo.Guides(GuideID),
    CONSTRAINT FK_Comments_ParentCommentID FOREIGN KEY (ParentCommentID) REFERENCES dbo.Comments(CommentID)
);
GO

-- 攻略点赞表(记录用户点赞,避免重复点赞)
CREATE TABLE dbo.GuideLikes (
    LikeID INT IDENTITY(1,1) PRIMARY KEY,
    UserID INT NOT NULL,
    GuideID INT NOT NULL,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    -- 唯一约束:一个用户只能点赞一次
    CONSTRAINT UQ_GuideLikes_UserGuide UNIQUE (UserID, GuideID),
    
    -- 外键
    CONSTRAINT FK_GuideLikes_UserID FOREIGN KEY (UserID) REFERENCES dbo.Users(UserID),
    CONSTRAINT FK_GuideLikes_GuideID FOREIGN KEY (GuideID) REFERENCES dbo.Guides(GuideID)
);
GO

2.4 系统配置表

-- 系统配置表
CREATE TABLE dbo.SystemConfig (
    ConfigID INT IDENTITY(1,1) PRIMARY KEY,
    ConfigKey NVARCHAR(100) NOT NULL,
    ConfigValue NVARCHAR(MAX),
    Description NVARCHAR(200),
    IsActive BIT NOT NULL DEFAULT 1,
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    UpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT UQ_SystemConfig_Key UNIQUE (ConfigKey)
);
GO

-- 活动日志表(用于审计和监控)
CREATE TABLE dbo.ActivityLogs (
    LogID BIGINT IDENTITY(1,1) PRIMARY KEY,
    UserID INT NULL, -- 可为空,表示未登录用户的操作
    ActivityType NVARCHAR(50) NOT NULL,
    Description NVARCHAR(500),
    IPAddress NVARCHAR(45), -- 支持IPv6
    UserAgent NVARCHAR(500),
    AdditionalData NVARCHAR(MAX), -- JSON格式的额外数据
    CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
    
    CONSTRAINT FK_ActivityLogs_UserID FOREIGN KEY (UserID) REFERENCES dbo.Users(UserID)
);
GO

3. 索引优化策略

3.1 核心表索引设计

-- 用户表索引
CREATE NONCLUSTERED INDEX IX_Users_Username ON dbo.Users(Username);
CREATE NONCLUSTERED INDEX IX_Users_Email ON dbo.Users(Email);
CREATE NONCLUSTERED INDEX IX_Users_Role ON dbo.Users(Role) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Users_LastLogin ON dbo.Users(LastLogin) WHERE IsActive = 1;
GO

-- 景点表索引
CREATE NONCLUSTERED INDEX IX_Attractions_City ON dbo.Attractions(City) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_Country ON dbo.Attractions(Country) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_CategoryID ON dbo.Attractions(CategoryID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_PriceRange ON dbo.Attractions(PriceRange) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_Featured ON dbo.Attractions(Featured) WHERE IsActive = 1 AND Featured = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_Rating ON dbo.Attractions(AverageRating) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Attractions_ViewCount ON dbo.Attractions(ViewCount) WHERE IsActive = 1;

-- 覆盖索引,优化景点列表查询
CREATE NONCLUSTERED INDEX IX_Attractions_List ON dbo.Attractions (
    City, Country, CategoryID, AverageRating
) INCLUDE (
    Name, Location, PriceRange, ViewCount, Featured
) WHERE IsActive = 1;
GO

-- 地理位置索引(如果使用空间查询)
-- CREATE SPATIAL INDEX IX_Attractions_Location ON dbo.Attractions(LocationPoint);
GO

-- 景点图片表索引
CREATE NONCLUSTERED INDEX IX_AttractionImages_AttractionID ON dbo.AttractionImages(AttractionID);
CREATE NONCLUSTERED INDEX IX_AttractionImages_IsPrimary ON dbo.AttractionImages(IsPrimary) WHERE IsPrimary = 1;
GO

-- 评论表索引
CREATE NONCLUSTERED INDEX IX_Reviews_AttractionID ON dbo.Reviews(AttractionID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Reviews_UserID ON dbo.Reviews(UserID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Reviews_Rating ON dbo.Reviews(Rating) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Reviews_CreatedAt ON dbo.Reviews(CreatedAt) WHERE IsActive = 1;
GO

-- 攻略表索引
CREATE NONCLUSTERED INDEX IX_Guides_AuthorID ON dbo.Guides(AuthorID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Guides_AttractionID ON dbo.Guides(AttractionID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Guides_Status ON dbo.Guides(Status) WHERE IsActive = 1 AND Status = 'published';
CREATE NONCLUSTERED INDEX IX_Guides_Featured ON dbo.Guides(Featured) WHERE IsActive = 1 AND Featured = 1;
CREATE NONCLUSTERED INDEX IX_Guides_DifficultyLevel ON dbo.Guides(DifficultyLevel) WHERE IsActive = 1 AND Status = 'published';
CREATE NONCLUSTERED INDEX IX_Guides_BudgetRange ON dbo.Guides(BudgetRange) WHERE IsActive = 1 AND Status = 'published';
CREATE NONCLUSTERED INDEX IX_Guides_ViewCount ON dbo.Guides(ViewCount) WHERE IsActive = 1 AND Status = 'published';
CREATE NONCLUSTERED INDEX IX_Guides_LikeCount ON dbo.Guides(LikeCount) WHERE IsActive = 1 AND Status = 'published';
CREATE NONCLUSTERED INDEX IX_Guides_CreatedAt ON dbo.Guides(CreatedAt) WHERE IsActive = 1 AND Status = 'published';

-- 覆盖索引,优化攻略列表查询
CREATE NONCLUSTERED INDEX IX_Guides_List ON dbo.Guides (
    AttractionID, Status, CreatedAt
) INCLUDE (
    Title, Excerpt, AuthorID, ViewCount, LikeCount, DifficultyLevel, BudgetRange
) WHERE IsActive = 1 AND Status = 'published';
GO

-- 标签相关索引
CREATE NONCLUSTERED INDEX IX_GuideTags_TagName ON dbo.GuideTags(TagName);
CREATE NONCLUSTERED INDEX IX_GuideTagAssociations_TagID ON dbo.GuideTagAssociations(TagID);
CREATE NONCLUSTERED INDEX IX_GuideTagAssociations_GuideID ON dbo.GuideTagAssociations(GuideID);
GO

-- 评论表索引
CREATE NONCLUSTERED INDEX IX_Comments_GuideID ON dbo.Comments(GuideID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Comments_UserID ON dbo.Comments(UserID) WHERE IsActive = 1;
CREATE NONCLUSTERED INDEX IX_Comments_ParentCommentID ON dbo.Comments(ParentCommentID) WHERE IsActive = 1 AND ParentCommentID IS NOT NULL;
CREATE NONCLUSTERED INDEX IX_Comments_CreatedAt ON dbo.Comments(CreatedAt) WHERE IsActive = 1;
GO

-- 活动日志索引
CREATE NONCLUSTERED INDEX IX_ActivityLogs_UserID ON dbo.ActivityLogs(UserID);
CREATE NONCLUSTERED INDEX IX_ActivityLogs_ActivityType ON dbo.ActivityLogs(ActivityType);
CREATE NONCLUSTERED INDEX IX_ActivityLogs_CreatedAt ON dbo.ActivityLogs(CreatedAt);
GO

3.2 全文搜索索引

-- 启用全文搜索
EXEC sp_fulltext_database 'enable';
GO

-- 创建全文目录
CREATE FULLTEXT CATALOG TravelPlatformFTCatalog AS DEFAULT;
GO

-- 为景点表创建全文索引
CREATE FULLTEXT INDEX ON dbo.Attractions (
    Name,
    Description,
    Location
) KEY INDEX PK_Attractions ON TravelPlatformFTCatalog;
GO

-- 为攻略表创建全文索引
CREATE FULLTEXT INDEX ON dbo.Guides (
    Title,
    Content,
    Excerpt
) KEY INDEX PK_Guides ON TravelPlatformFTCatalog;
GO

4. 存储过程和函数

4.1 景点相关存储过程

-- 获取景点列表的存储过程
CREATE PROCEDURE dbo.sp_GetAttractions
    @Page INT = 1,
    @PageSize INT = 20,
    @City NVARCHAR(100) = NULL,
    @Country NVARCHAR(100) = NULL,
    @CategoryID INT = NULL,
    @PriceRange NVARCHAR(20) = NULL,
    @SearchTerm NVARCHAR(200) = NULL,
    @MinRating DECIMAL(3,2) = NULL,
    @Featured BIT = NULL,
    @SortBy NVARCHAR(50) = 'created_at',
    @SortOrder NVARCHAR(10) = 'desc'
AS
BEGIN
    SET NOCOUNT ON;
    
    DECLARE @Offset INT = (@Page - 1) * @PageSize;
    
    -- 构建动态SQL以避免参数嗅探问题
    DECLARE @SQL NVARCHAR(MAX);
    DECLARE @Params NVARCHAR(MAX);
    
    SET @SQL = '
    WITH FilteredAttractions AS (
        SELECT 
            a.AttractionID,
            a.Name,
            a.Description,
            a.Location,
            a.City,
            a.Country,
            a.CategoryID,
            ac.CategoryName,
            a.PriceRange,
            a.OpeningHours,
            a.AverageRating,
            a.ReviewCount,
            a.ViewCount,
            a.Featured,
            a.Latitude,
            a.Longitude,
            a.CreatedAt,
            ROW_NUMBER() OVER (ORDER BY ' + 
                CASE 
                    WHEN @SortBy = 'rating' THEN 'a.AverageRating'
                    WHEN @SortBy = 'views' THEN 'a.ViewCount'
                    WHEN @SortBy = 'reviews' THEN 'a.ReviewCount'
                    ELSE 'a.CreatedAt'
                END + ' ' + @SortOrder + ') AS RowNum,
            COUNT(*) OVER() AS TotalCount
        FROM dbo.Attractions a
        INNER JOIN dbo.AttractionCategories ac ON a.CategoryID = ac.CategoryID
        WHERE a.IsActive = 1
    ';
    
    -- 添加过滤条件
    IF @City IS NOT NULL
        SET @SQL = @SQL + ' AND a.City LIKE @City';
    
    IF @Country IS NOT NULL
        SET @SQL = @SQL + ' AND a.Country LIKE @Country';
    
    IF @CategoryID IS NOT NULL
        SET @SQL = @SQL + ' AND a.CategoryID = @CategoryID';
    
    IF @PriceRange IS NOT NULL
        SET @SQL = @SQL + ' AND a.PriceRange = @PriceRange';
    
    IF @MinRating IS NOT NULL
        SET @SQL = @SQL + ' AND a.AverageRating >= @MinRating';
    
    IF @Featured IS NOT NULL
        SET @SQL = @SQL + ' AND a.Featured = @Featured';
    
    IF @SearchTerm IS NOT NULL
        SET @SQL = @SQL + ' AND CONTAINS((a.Name, a.Description, a.Location), @SearchTerm)';
    
    SET @SQL = @SQL + '
    )
    SELECT 
        AttractionID,
        Name,
        Description,
        Location,
        City,
        Country,
        CategoryID,
        CategoryName,
        PriceRange,
        OpeningHours,
        AverageRating,
        ReviewCount,
        ViewCount,
        Featured,
        Latitude,
        Longitude,
        CreatedAt,
        TotalCount
    FROM FilteredAttractions
    WHERE RowNum > @Offset AND RowNum <= @Offset + @PageSize
    ORDER BY RowNum;
    ';
    
    SET @Params = '
        @Page INT,
        @PageSize INT,
        @City NVARCHAR(100),
        @Country NVARCHAR(100),
        @CategoryID INT,
        @PriceRange NVARCHAR(20),
        @SearchTerm NVARCHAR(200),
        @MinRating DECIMAL(3,2),
        @Featured BIT,
        @Offset INT
    ';
    
    EXEC sp_executesql @SQL, @Params,
        @Page = @Page,
        @PageSize = @PageSize,
        @City = @City,
        @Country = @Country,
        @CategoryID = @CategoryID,
        @PriceRange = @PriceRange,
        @SearchTerm = @SearchTerm,
        @MinRating = @MinRating,
        @Featured = @Featured,
        @Offset = @Offset;
END;
GO

-- 获取景点详情的存储过程
CREATE PROCEDURE dbo.sp_GetAttractionDetail
    @AttractionID INT
AS
BEGIN
    SET NOCOUNT ON;
    
    -- 更新浏览次数
    UPDATE dbo.Attractions 
    SET ViewCount = ViewCount + 1
    WHERE AttractionID = @AttractionID;
    
    -- 获取景点基本信息
    SELECT 
        a.AttractionID,
        a.Name,
        a.Description,
        a.Location,
        a.City,
        a.Country,
        a.CategoryID,
        ac.CategoryName,
        a.PriceRange,
        a.OpeningHours,
        a.BestSeason,
        a.ContactInfo,
        a.Website,
        a.Latitude,
        a.Longitude,
        a.AverageRating,
        a.ReviewCount,
        a.ViewCount,
        a.Featured,
        a.CreatedAt,
        a.UpdatedAt
    FROM dbo.Attractions a
    INNER JOIN dbo.AttractionCategories ac ON a.CategoryID = ac.CategoryID
    WHERE a.AttractionID = @AttractionID AND a.IsActive = 1;
    
    -- 获取景点图片
    SELECT 
        ImageID,
        ImageURL,
        Caption,
        IsPrimary,
        UploadOrder
    FROM dbo.AttractionImages
    WHERE AttractionID = @AttractionID
    ORDER BY IsPrimary DESC, UploadOrder ASC;
    
    -- 获取相关攻略数量
    SELECT COUNT(*) AS GuideCount
    FROM dbo.Guides
    WHERE AttractionID = @AttractionID AND IsActive = 1 AND Status = 'published';
    
    -- 获取最新评论(前5条)
    SELECT TOP 5
        r.ReviewID,
        r.UserID,
        u.Username,
        u.AvatarURL,
        r.Rating,
        r.Title,
        r.Content,
        r.VisitDate,
        r.CreatedAt
    FROM dbo.Reviews r
    INNER JOIN dbo.Users u ON r.UserID = u.UserID
    WHERE r.AttractionID = @AttractionID AND r.IsActive = 1
    ORDER BY r.CreatedAt DESC;
END;
GO

-- 创建或更新景点评论的存储过程
CREATE PROCEDURE dbo.sp_UpsertReview
    @UserID INT,
    @AttractionID INT,
    @Rating INT,
    @Title NVARCHAR(200) = NULL,
    @Content NVARCHAR(MAX) = NULL,
    @VisitDate DATE = NULL
AS
BEGIN
    SET NOCOUNT ON;
    
    BEGIN TRANSACTION;
    
    BEGIN TRY
        DECLARE @ExistingReviewID INT;
        
        -- 检查是否已存在评论
        SELECT @ExistingReviewID = ReviewID
        FROM dbo.Reviews
        WHERE UserID = @UserID AND AttractionID = @AttractionID AND IsActive = 1;
        
        IF @ExistingReviewID IS NOT NULL
        BEGIN
            -- 更新现有评论
            UPDATE dbo.Reviews
            SET 
                Rating = @Rating,
                Title = @Title,
                Content = @Content,
                VisitDate = @VisitDate,
                UpdatedAt = GETUTCDATE()
            WHERE ReviewID = @ExistingReviewID;
        END
        ELSE
        BEGIN
            -- 插入新评论
            INSERT INTO dbo.Reviews (UserID, AttractionID, Rating, Title, Content, VisitDate)
            VALUES (@UserID, @AttractionID, @Rating, @Title, @Content, @VisitDate);
            
            SET @ExistingReviewID = SCOPE_IDENTITY();
        END
        
        -- 更新景点的平均评分和评论计数
        UPDATE a
        SET 
            AverageRating = (
                SELECT AVG(CAST(Rating AS DECIMAL(3,2)))
                FROM dbo.Reviews
                WHERE AttractionID = @AttractionID AND IsActive = 1
            ),
            ReviewCount = (
                SELECT COUNT(*)
                FROM dbo.Reviews
                WHERE AttractionID = @AttractionID AND IsActive = 1
            ),
            UpdatedAt = GETUTCDATE()
        FROM dbo.Attractions a
        WHERE a.AttractionID = @AttractionID;
        
        COMMIT TRANSACTION;
        
        -- 返回更新后的评论信息
        SELECT 
            ReviewID,
            UserID,
            AttractionID,
            Rating,
            Title,
            Content,
            VisitDate,
            CreatedAt,
            UpdatedAt
        FROM dbo.Reviews
        WHERE ReviewID = @ExistingReviewID;
        
    END TRY
    BEGIN CATCH
        ROLLBACK TRANSACTION;
        THROW;
    END CATCH
END;
GO

4.2 攻略相关存储过程

-- 获取攻略列表的存储过程
CREATE PROCEDURE dbo.sp_GetGuides
    @Page INT = 1,
    @PageSize INT = 20,
    @AttractionID INT = NULL,
    @AuthorID INT = NULL,
    @TravelSeason NVARCHAR(100) = NULL,
    @BudgetRange NVARCHAR(50) = NULL,
    @DifficultyLevel NVARCHAR(20) = NULL,
    @Featured BIT = NULL,
    @SearchTerm NVARCHAR(200) = NULL,
    @Tags NVARCHAR(500) = NULL, -- 逗号分隔的标签列表
    @SortBy NVARCHAR(50) = 'created_at',
    @SortOrder NVARCHAR(10) = 'desc'
AS
BEGIN
    SET NOCOUNT ON;
    
    DECLARE @Offset INT = (@Page - 1) * @PageSize;
    
    -- 构建动态SQL
    DECLARE @SQL NVARCHAR(MAX);
    DECLARE @Params NVARCHAR(MAX);
    
    SET @SQL = '
    WITH FilteredGuides AS (
        SELECT DISTINCT
            g.GuideID,
            g.Title,
            g.Excerpt,
            g.AuthorID,
            u.Username AS AuthorName,
            u.AvatarURL AS AuthorAvatar,
            g.AttractionID,
            a.Name AS AttractionName,
            g.TravelSeason,
            g.BudgetRange,
            g.DurationDays,
            g.DifficultyLevel,
            g.ViewCount,
            g.LikeCount,
            g.ShareCount,
            g.Featured,
            g.CreatedAt,
            ROW_NUMBER() OVER (ORDER BY ' + 
                CASE 
                    WHEN @SortBy = 'views' THEN 'g.ViewCount'
                    WHEN @SortBy = 'likes' THEN 'g.LikeCount'
                    WHEN @SortBy = 'popular' THEN '(g.ViewCount + g.LikeCount * 2)'
                    ELSE 'g.CreatedAt'
                END + ' ' + @SortOrder + ') AS RowNum,
            COUNT(*) OVER() AS TotalCount
        FROM dbo.Guides g
        INNER JOIN dbo.Users u ON g.AuthorID = u.UserID
        INNER JOIN dbo.Attractions a ON g.AttractionID = a.AttractionID
        WHERE g.IsActive = 1 AND g.Status = ''published''
    ';
    
    -- 添加标签过滤(如果提供了标签)
    IF @Tags IS NOT NULL
    BEGIN
        SET @SQL = @SQL + '
        INNER JOIN dbo.GuideTagAssociations gta ON g.GuideID = gta.GuideID
        INNER JOIN dbo.GuideTags gt ON gta.TagID = gt.TagID
        AND gt.TagName IN (SELECT value FROM STRING_SPLIT(@Tags, '',''))
        ';
    END
    
    -- 添加其他过滤条件
    IF @AttractionID IS NOT NULL
        SET @SQL = @SQL + ' AND g.AttractionID = @AttractionID';
    
    IF @AuthorID IS NOT NULL
        SET @SQL = @SQL + ' AND g.AuthorID = @AuthorID';
    
    IF @TravelSeason IS NOT NULL
        SET @SQL = @SQL + ' AND g.TravelSeason = @TravelSeason';
    
    IF @BudgetRange IS NOT NULL
        SET @SQL = @SQL + ' AND g.BudgetRange = @BudgetRange';
    
    IF @DifficultyLevel IS NOT NULL
        SET @SQL = @SQL + ' AND g.DifficultyLevel = @DifficultyLevel';
    
    IF @Featured IS NOT NULL
        SET @SQL = @SQL + ' AND g.Featured = @Featured';
    
    IF @SearchTerm IS NOT NULL
        SET @SQL = @SQL + ' AND CONTAINS((g.Title, g.Content, g.Excerpt), @SearchTerm)';
    
    SET @SQL = @SQL + '
    )
    SELECT 
        GuideID,
        Title,
        Excerpt,
        AuthorID,
        AuthorName,
        AuthorAvatar,
        AttractionID,
        AttractionName,
        TravelSeason,
        BudgetRange,
        DurationDays,
        DifficultyLevel,
        ViewCount,
        LikeCount,
        ShareCount,
        Featured,
        CreatedAt,
        TotalCount
    FROM FilteredGuides
    WHERE RowNum > @Offset AND RowNum <= @Offset + @PageSize
    ORDER BY RowNum;
    ';
    
    SET @Params = '
        @Page INT,
        @PageSize INT,
        @AttractionID INT,
        @AuthorID INT,
        @TravelSeason NVARCHAR(100),
        @BudgetRange NVARCHAR(50),
        @DifficultyLevel NVARCHAR(20),
        @Featured BIT,
        @SearchTerm NVARCHAR(200),
        @Tags NVARCHAR(500),
        @Offset INT
    ';
    
    EXEC sp_executesql @SQL, @Params,
        @Page = @Page,
        @PageSize = @PageSize,
        @AttractionID = @AttractionID,
        @AuthorID = @AuthorID,
        @TravelSeason = @TravelSeason,
        @BudgetRange = @BudgetRange,
        @DifficultyLevel = @DifficultyLevel,
        @Featured = @Featured,
        @SearchTerm = @SearchTerm,
        @Tags = @Tags,
        @Offset = @Offset;
END;
GO

-- 点赞攻略的存储过程
CREATE PROCEDURE dbo.sp_LikeGuide
    @UserID INT,
    @GuideID INT
AS
BEGIN
    SET NOCOUNT ON;
    
    BEGIN TRANSACTION;
    
    BEGIN TRY
        -- 检查是否已经点赞
        IF NOT EXISTS (
            SELECT 1 
            FROM dbo.GuideLikes 
            WHERE UserID = @UserID AND GuideID = @GuideID
        )
        BEGIN
            -- 插入点赞记录
            INSERT INTO dbo.GuideLikes (UserID, GuideID)
            VALUES (@UserID, @GuideID);
            
            -- 更新攻略的点赞计数
            UPDATE dbo.Guides
            SET LikeCount = LikeCount + 1,
                UpdatedAt = GETUTCDATE()
            WHERE GuideID = @GuideID;
            
            -- 返回新的点赞计数
            SELECT LikeCount
            FROM dbo.Guides
            WHERE GuideID = @GuideID;
        END
        ELSE
        BEGIN
            -- 已经点赞过,返回当前计数
            SELECT LikeCount
            FROM dbo.Guides
            WHERE GuideID = @GuideID;
        END
        
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        ROLLBACK TRANSACTION;
        THROW;
    END CATCH
END;
GO

4.3 统计和报表存储过程

-- 获取平台统计信息的存储过程
CREATE PROCEDURE dbo.sp_GetPlatformStatistics
AS
BEGIN
    SET NOCOUNT ON;
    
    -- 用户统计
    SELECT 
        (SELECT COUNT(*) FROM dbo.Users WHERE IsActive = 1) AS TotalUsers,
        (SELECT COUNT(*) FROM dbo.Users WHERE IsActive = 1 AND Role = 'admin') AS AdminUsers,
        (SELECT COUNT(*) FROM dbo.Users WHERE LastLogin >= DATEADD(DAY, -30, GETUTCDATE())) AS ActiveUsersLast30Days;
    
    -- 景点统计
    SELECT 
        (SELECT COUNT(*) FROM dbo.Attractions WHERE IsActive = 1) AS TotalAttractions,
        (SELECT COUNT(*) FROM dbo.Attractions WHERE IsActive = 1 AND Featured = 1) AS FeaturedAttractions,
        (SELECT ISNULL(SUM(ViewCount), 0) FROM dbo.Attractions WHERE IsActive = 1) AS TotalAttractionViews,
        (SELECT ISNULL(AVG(AverageRating), 0) FROM dbo.Attractions WHERE IsActive = 1 AND ReviewCount > 0) AS AverageAttractionRating;
    
    -- 攻略统计
    SELECT 
        (SELECT COUNT(*) FROM dbo.Guides WHERE IsActive = 1 AND Status = 'published') AS TotalPublishedGuides,
        (SELECT COUNT(*) FROM dbo.Guides WHERE IsActive = 1 AND Featured = 1 AND Status = 'published') AS FeaturedGuides,
        (SELECT ISNULL(SUM(ViewCount), 0) FROM dbo.Guides WHERE IsActive = 1 AND Status = 'published') AS TotalGuideViews,
        (SELECT ISNULL(SUM(LikeCount), 0) FROM dbo.Guides WHERE IsActive = 1 AND Status = 'published') AS TotalGuideLikes;
    
    -- 评论统计
    SELECT 
        (SELECT COUNT(*) FROM dbo.Reviews WHERE IsActive = 1) AS TotalReviews,
        (SELECT COUNT(*) FROM dbo.Comments WHERE IsActive = 1) AS TotalComments;
    
    -- 按类别的景点分布
    SELECT 
        ac.CategoryName,
        COUNT(*) AS AttractionCount,
        AVG(a.AverageRating) AS AverageRating,
        SUM(a.ViewCount) AS TotalViews
    FROM dbo.Attractions a
    INNER JOIN dbo.AttractionCategories ac ON a.CategoryID = ac.CategoryID
    WHERE a.IsActive = 1
    GROUP BY ac.CategoryID, ac.CategoryName
    ORDER BY AttractionCount DESC;
    
    -- 热门标签
    SELECT TOP 10
        gt.TagName,
        COUNT(*) AS UsageCount
    FROM dbo.GuideTagAssociations gta
    INNER JOIN dbo.GuideTags gt ON gta.TagID = gt.TagID
    INNER JOIN dbo.Guides g ON gta.GuideID = g.GuideID
    WHERE g.IsActive = 1 AND g.Status = 'published'
    GROUP BY gt.TagID, gt.TagName
    ORDER BY UsageCount DESC;
END;
GO

-- 获取用户活动统计的存储过程
CREATE PROCEDURE dbo.sp_GetUserActivityStats
    @UserID INT
AS
BEGIN
    SET NOCOUNT ON;
    
    -- 用户基本信息
    SELECT 
        Username,
        DisplayName,
        AvatarURL,
        Bio,
        CreatedAt,
        LastLogin
    FROM dbo.Users
    WHERE UserID = @UserID;
    
    -- 攻略统计
    SELECT 
        COUNT(*) AS TotalGuides,
        SUM(ViewCount) AS TotalViews,
        SUM(LikeCount) AS TotalLikes,
        AVG(CAST(LikeCount AS FLOAT) / NULLIF(ViewCount, 0)) AS EngagementRate
    FROM dbo.Guides
    WHERE AuthorID = @UserID AND IsActive = 1 AND Status = 'published';
    
    -- 按状态的攻略分布
    SELECT 
        Status,
        COUNT(*) AS GuideCount
    FROM dbo.Guides
    WHERE AuthorID = @UserID AND IsActive = 1
    GROUP BY Status;
    
    -- 最近活动
    SELECT TOP 10
        ActivityType,
        Description,
        CreatedAt
    FROM dbo.ActivityLogs
    WHERE UserID = @UserID
    ORDER BY CreatedAt DESC;
END;
GO

5. 视图设计

5.1 常用查询视图

-- 景点详细信息的视图
CREATE VIEW dbo.vw_AttractionDetails
AS
SELECT 
    a.AttractionID,
    a.Name,
    a.Description,
    a.Location,
    a.City,
    a.Country,
    a.CategoryID,
    ac.CategoryName,
    a.PriceRange,
    a.OpeningHours,
    a.BestSeason,
    a.ContactInfo,
    a.Website,
    a.Latitude,
    a.Longitude,
    a.AverageRating,
    a.ReviewCount,
    a.ViewCount,
    a.Featured,
    a.CreatedAt,
    a.UpdatedAt,
    -- 主图片
    (SELECT TOP 1 ImageURL FROM dbo.AttractionImages WHERE AttractionID = a.AttractionID AND IsPrimary = 1) AS PrimaryImageURL,
    -- 图片数量
    (SELECT COUNT(*) FROM dbo.AttractionImages WHERE AttractionID = a.AttractionID) AS ImageCount,
    -- 攻略数量
    (SELECT COUNT(*) FROM dbo.Guides WHERE AttractionID = a.AttractionID AND IsActive = 1 AND Status = 'published') AS GuideCount
FROM dbo.Attractions a
INNER JOIN dbo.AttractionCategories ac ON a.CategoryID = ac.CategoryID
WHERE a.IsActive = 1;
GO

-- 攻略详细信息的视图
CREATE VIEW dbo.vw_GuideDetails
AS
SELECT 
    g.GuideID,
    g.Title,
    g.Content,
    g.Excerpt,
    g.AuthorID,
    u.Username AS AuthorName,
    u.AvatarURL AS AuthorAvatar,
    g.AttractionID,
    a.Name AS AttractionName,
    a.City AS AttractionCity,
    a.Country AS AttractionCountry,
    g.TravelSeason,
    g.BudgetRange,
    g.DurationDays,
    g.DifficultyLevel,
    g.ViewCount,
    g.LikeCount,
    g.ShareCount,
    g.Featured,
    g.Status,
    g.CreatedAt,
    g.UpdatedAt,
    -- 标签列表(逗号分隔)
    STUFF((
        SELECT ', ' + gt.TagName
        FROM dbo.GuideTagAssociations gta
        INNER JOIN dbo.GuideTags gt ON gta.TagID = gt.TagID
        WHERE gta.GuideID = g.GuideID
        FOR XML PATH(''), TYPE
    ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS Tags,
    -- 图片数量
    (SELECT COUNT(*) FROM dbo.GuideImages WHERE GuideID = g.GuideID) AS ImageCount,
    -- 评论数量
    (SELECT COUNT(*) FROM dbo.Comments WHERE GuideID = g.GuideID AND IsActive = 1) AS CommentCount
FROM dbo.Guides g
INNER JOIN dbo.Users u ON g.AuthorID = u.UserID
INNER JOIN dbo.Attractions a ON g.AttractionID = a.AttractionID
WHERE g.IsActive = 1;
GO

-- 热门景点视图
CREATE VIEW dbo.vw_PopularAttractions
AS
SELECT TOP 50
    AttractionID,
    Name,
    City,
    Country,
    AverageRating,
    ReviewCount,
    ViewCount,
    Featured,
    PrimaryImageURL,
    ROW_NUMBER() OVER (ORDER BY (ViewCount + ReviewCount * 10 + AverageRating * 100) DESC) AS PopularityRank
FROM dbo.vw_AttractionDetails
WHERE AverageRating >= 3.5 AND ReviewCount >= 5
ORDER BY (ViewCount + ReviewCount * 10 + AverageRating * 100) DESC;
GO

6. 性能优化策略

6.1 查询性能监控

-- 查找缺失索引
SELECT 
    migs.avg_total_user_cost * (migs.avg_user_impact / 100.0) * (migs.user_seeks + migs.user_scans) AS improvement_measure,
    DB_NAME(mid.database_id) AS database_name,
    mid.[statement] AS table_name,
    mid.equality_columns,
    mid.inequality_columns,
    mid.included_columns
FROM sys.dm_db_missing_index_groups mig
INNER JOIN sys.dm_db_missing_index_group_stats migs ON migs.group_handle = mig.index_group_handle
INNER JOIN sys.dm_db_missing_index_details mid ON mig.index_handle = mid.index_handle
WHERE mid.database_id = DB_ID('TravelPlatform')
ORDER BY improvement_measure DESC;
GO

-- 监控慢查询
SELECT 
    TOP 10 
    total_elapsed_time / execution_count AS avg_elapsed_time,
    execution_count,
    total_worker_time / execution_count AS avg_cpu_time,
    total_logical_reads / execution_count AS avg_logical_reads,
    total_physical_reads / execution_count AS avg_physical_reads,
    SUBSTRING(st.text, (qs.statement_start_offset/2)+1, 
        ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
          ELSE qs.statement_end_offset END - qs.statement_start_offset)/2) + 1) AS statement_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY avg_elapsed_time DESC;
GO

6.2 数据库维护作业

-- 更新统计信息
CREATE PROCEDURE dbo.sp_UpdateStatistics
AS
BEGIN
    -- 更新所有表的统计信息
    EXEC sp_msforeachtable 'UPDATE STATISTICS ? WITH FULLSCAN';
    
    -- 记录维护日志
    INSERT INTO dbo.ActivityLogs (ActivityType, Description)
    VALUES ('Maintenance', 'Statistics updated for all tables');
END;
GO

-- 清理过期会话
CREATE PROCEDURE dbo.sp_CleanupExpiredSessions
AS
BEGIN
    DELETE FROM dbo.UserSessions
    WHERE ExpiresAt < GETUTCDATE();
    
    -- 记录清理日志
    INSERT INTO dbo.ActivityLogs (ActivityType, Description)
    VALUES ('Maintenance', 'Expired user sessions cleaned up');
END;
GO

7. 安全配置

7.1 数据库角色和权限

-- 创建应用程序角色
CREATE ROLE TravelPlatformApp;
GO

-- 授予基本的CRUD权限
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Users TO TravelPlatformApp;
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Attractions TO TravelPlatformApp;
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Guides TO TravelPlatformApp;
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Reviews TO TravelPlatformApp;
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Comments TO TravelPlatformApp;
GRANT EXECUTE ON SCHEMA::dbo TO TravelPlatformApp;
GO

-- 创建只读角色用于报表
CREATE ROLE TravelPlatformReadOnly;
GO

GRANT SELECT ON SCHEMA::dbo TO TravelPlatformReadOnly;
GRANT EXECUTE ON dbo.sp_GetPlatformStatistics TO TravelPlatformReadOnly;
GRANT EXECUTE ON dbo.sp_GetUserActivityStats TO TravelPlatformReadOnly;
GO

8. 数据初始化

8.1 初始数据插入

-- 插入默认分类
INSERT INTO dbo.AttractionCategories (CategoryName, Description, SortOrder)
VALUES 
    ('自然景观', '山川、湖泊、森林等自然风光', 1),
    ('历史遗迹', '古代建筑、遗址、文物等', 2),
    ('现代建筑', '现代城市建筑、地标等', 3),
    ('主题公园', '游乐园、主题公园等', 4),
    ('博物馆', '各类博物馆、展览馆', 5),
    ('美食购物', '美食街区、购物中心等', 6),
    ('其他', '其他类型的旅游景点', 7);
GO

-- 插入默认标签
INSERT INTO dbo.GuideTags (TagName)
VALUES 
    ('文化'), ('历史'), ('摄影'), ('美食'), ('购物'),
    ('亲子'), ('情侣'), ('徒步'), ('自驾'), ('省钱'),
    ('奢华'), ('冒险'), ('休闲'), ('周末游'), ('深度游');
GO

-- 插入系统配置
INSERT INTO dbo.SystemConfig (ConfigKey, ConfigValue, Description)
VALUES 
    ('SiteName', '旅游信息平台', '网站名称'),
    ('ItemsPerPage', '20', '每页显示项目数'),
    ('MaxUploadSize', '10485760', '最大文件上传大小(字节)'),
    ('AllowUserRegistration', 'true', '是否允许用户注册');
GO

这套SQL Server数据库设计方案提供了:

  1. 规范化的表结构:遵循数据库设计范式,减少数据冗余
  2. 全面的索引策略:为常用查询字段创建合适的索引
  3. 存储过程封装:将复杂业务逻辑封装在存储过程中
  4. 视图抽象:简化常用复杂查询
  5. 性能监控:内置查询性能监控和维护工具
  6. 安全控制:细粒度的权限管理
  7. 扩展性考虑:支持未来功能扩展

通过这套设计方案,可以确保旅游信息平台的数据高效存储和快速检索,支撑高并发访问场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小宝哥Code

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值