You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

15 KiB

Projects 表 V1/V2 设计方案

方案名称:Projects 表多态所有者与预算积分设计
创建时间:2025-01-14
版本:v1.0


1. 背景与目标

1.1 业务需求

V1 阶段(当前)

  • 只支持个人用户
  • 项目归属于个人
  • 积分从个人账户扣除

V2 阶段(未来)

  • 支持企业账号
  • 项目可以归属于企业
  • 企业可以为项目充值预算积分
  • 个人在企业项目中工作时,使用项目预算积分

1.2 设计目标

  1. V1 实现简单:只处理个人用户场景,复杂度低
  2. V2 平滑升级:无需修改表结构,只需启用预留字段
  3. 扩展性强:支持未来更多所有者类型
  4. 性能优秀:索引优化,查询高效

2. 核心设计

2.1 多态所有者模式

设计思路

  • 使用 owner_type + owner_id 组合表示所有者
  • owner_type 可以是 'user' 或 'organization'
  • owner_id 根据 owner_type 指向不同的表

优势

  • 灵活:支持多种所有者类型
  • 扩展:新增类型只需添加枚举值
  • 统一:查询和权限逻辑统一处理

2.2 项目预算积分

设计思路

  • 项目可以有独立的积分预算(ai_credits_budget
  • 记录已消耗的预算(budget_consumed
  • 消耗优先级:项目预算 > 所有者积分

优势

  • 灵活:企业可以为项目单独充值
  • 可控:项目预算独立管理,不影响企业总积分
  • 透明:消耗记录清晰,便于审计

3. 表结构设计

3.1 完整 DDL

-- 项目类型枚举
CREATE TYPE project_type AS ENUM ('mine', 'collab');

-- 项目表
CREATE TABLE projects (
    project_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- 基本信息
    name TEXT NOT NULL,
    description TEXT,
    type project_type NOT NULL DEFAULT 'mine',

    -- 多态所有者(V1: user, V2: user | organization)
    owner_type TEXT NOT NULL DEFAULT 'user' CHECK (owner_type IN ('user', 'organization')),
    owner_id BIGINT NOT NULL,

    -- 文件夹归属
    folder_id BIGINT REFERENCES folders(folder_id) ON DELETE SET NULL,

    -- 封面图片
    thumbnail_url TEXT,
    cover_image_id BIGINT REFERENCES attachments(attachment_id) ON DELETE SET NULL,

    -- 项目预算积分(V1: 0, V2: 企业充值)
    ai_credits_budget INTEGER NOT NULL DEFAULT 0,
    budget_consumed INTEGER NOT NULL DEFAULT 0,

    -- 项目设置
    settings JSONB NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(settings) = 'object'),

    -- 状态
    status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),

    -- 时间戳
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,

    -- 唯一约束
    CONSTRAINT projects_name_unique UNIQUE (folder_id, name) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_projects_owner ON projects (owner_type, owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE deleted_at IS NULL AND folder_id IS NOT NULL;
CREATE INDEX idx_projects_type ON projects (type) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_status ON projects (status) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_created_at ON projects (created_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_updated_at ON projects (updated_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_cover_image_id ON projects (cover_image_id) WHERE cover_image_id IS NOT NULL;
CREATE INDEX idx_projects_settings_gin ON projects USING GIN (settings);
CREATE INDEX idx_projects_name_trgm ON projects USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_budget ON projects (ai_credits_budget) WHERE deleted_at IS NULL AND ai_credits_budget > 0;

-- 注释
COMMENT ON TABLE projects IS '项目表,支持个人和企业项目';
COMMENT ON COLUMN projects.owner_type IS 'V1: 固定为 user, V2: 支持 organization';
COMMENT ON COLUMN projects.owner_id IS '所有者ID,根据 owner_type 指向 users.user_id 或 organizations.organization_id';
COMMENT ON COLUMN projects.ai_credits_budget IS 'V1: 保持为 0, V2: 企业为项目充值的专属积分预算';
COMMENT ON COLUMN projects.budget_consumed IS 'V1: 保持为 0, V2: 已消耗的项目预算积分';

-- 触发器
CREATE TRIGGER update_projects_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.2 字段说明

字段名 类型 说明 V1 使用 V2 使用
project_id BIGINT 主键
name TEXT 项目名称
description TEXT 项目描述
type ENUM 项目类型(mine/collab)
owner_type TEXT 所有者类型 (固定 'user') ('user' 或 'organization')
owner_id BIGINT 所有者ID (指向 users) (指向 users 或 organizations)
folder_id BIGINT 文件夹ID
thumbnail_url TEXT 缩略图URL
cover_image_id BIGINT 封面图片ID
ai_credits_budget INTEGER 项目预算积分 (保持 0) (企业充值)
budget_consumed INTEGER 已消耗预算 (保持 0) (记录消耗)
settings JSONB 项目设置
status TEXT 项目状态
created_at TIMESTAMPTZ 创建时间
updated_at TIMESTAMPTZ 更新时间
deleted_at TIMESTAMPTZ 删除时间

4. V1 实现(当前)

4.1 业务规则

  1. 项目创建

    • owner_type 固定为 'user'
    • owner_id 设置为当前用户的 user_id
    • ai_credits_budgetbudget_consumed 保持为 0
  2. 积分消耗

    • 从项目所有者的 users.ai_credits_balance 扣除
    • 不使用项目预算
  3. 项目查询

    • 我的项目:owner_type='user' AND owner_id=当前用户ID
    • 协作项目:通过 project_members 表查询

4.2 代码示例

# V1: 创建项目
async def create_project(self, user_id: int, data: dict) -> Project:
    project = Project(
        name=data['name'],
        description=data.get('description'),
        type=data.get('type', 'mine'),
        owner_type='user',  # V1 固定为 'user'
        owner_id=user_id,
        folder_id=data.get('folder_id'),
        ai_credits_budget=0,  # V1 不使用
        budget_consumed=0,    # V1 不使用
        settings=data.get('settings', {})
    )
    return await self.repository.create(project)

# V1: 消耗积分
async def consume_credits(self, project_id: int, amount: int) -> bool:
    project = await self.repository.get_by_id(project_id)

    # V1: 只从用户积分扣除
    if project.owner_type == 'user':
        user = await self.user_repo.get_by_id(project.owner_id)
        if user.ai_credits_balance < amount:
            raise InsufficientCreditsError("积分不足")

        await self.user_repo.update(project.owner_id, {
            'ai_credits_balance': user.ai_credits_balance - amount,
            'total_credits_consumed': user.total_credits_consumed + amount
        })

    return True

5. V2 升级(未来)

5.1 新增功能

  1. 企业账号

    • 创建 organizations
    • 企业有独立的积分余额
  2. 企业项目

    • 支持 owner_type='organization'
    • 企业可以创建项目
  3. 项目预算

    • 企业可以为项目充值预算
    • 启用 ai_credits_budgetbudget_consumed 字段
  4. 积分消耗优先级

    • 优先使用项目预算
    • 预算不足时使用所有者积分

5.2 数据迁移

-- V2 升级:无需修改 projects 表结构
-- 只需创建 organizations 表

CREATE TABLE organizations (
    organization_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    display_name TEXT,
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    ai_credits_balance INTEGER NOT NULL DEFAULT 0,
    total_recharged_amount NUMERIC(10, 2) NOT NULL DEFAULT 0.00,
    total_credits_earned INTEGER NOT NULL DEFAULT 0,
    total_credits_consumed INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

-- 现有项目数据无需迁移,owner_type 已经是 'user'

5.3 代码示例

# V2: 消耗积分(支持项目预算)
async def consume_credits(self, project_id: int, amount: int) -> bool:
    project = await self.repository.get_by_id(project_id)

    # 优先使用项目预算
    if project.ai_credits_budget >= amount:
        await self.repository.update(project_id, {
            'ai_credits_budget': project.ai_credits_budget - amount,
            'budget_consumed': project.budget_consumed + amount
        })
        return True

    # 项目预算不足,使用所有者积分
    if project.owner_type == 'user':
        user = await self.user_repo.get_by_id(project.owner_id)
        if user.ai_credits_balance < amount:
            raise InsufficientCreditsError("积分不足")

        await self.user_repo.update(project.owner_id, {
            'ai_credits_balance': user.ai_credits_balance - amount,
            'total_credits_consumed': user.total_credits_consumed + amount
        })

    elif project.owner_type == 'organization':
        org = await self.org_repo.get_by_id(project.owner_id)
        if org.ai_credits_balance < amount:
            raise InsufficientCreditsError("企业积分不足")

        await self.org_repo.update(project.owner_id, {
            'ai_credits_balance': org.ai_credits_balance - amount,
            'total_credits_consumed': org.total_credits_consumed + amount
        })

    return True

# V2: 为项目充值预算
async def recharge_project_budget(
    self,
    project_id: int,
    amount: int,
    user_id: int
) -> bool:
    project = await self.repository.get_by_id(project_id)

    # 只有企业项目支持预算充值
    if project.owner_type != 'organization':
        raise ValidationError("只有企业项目支持预算充值")

    # 从企业积分转移到项目预算
    org = await self.org_repo.get_by_id(project.owner_id)
    if org.ai_credits_balance < amount:
        raise InsufficientCreditsError("企业积分不足")

    # 扣除企业积分
    await self.org_repo.update(project.owner_id, {
        'ai_credits_balance': org.ai_credits_balance - amount
    })

    # 增加项目预算
    await self.repository.update(project_id, {
        'ai_credits_budget': project.ai_credits_budget + amount
    })

    return True

6. 查询示例

6.1 V1 查询

-- 查询我的项目
SELECT * FROM projects
WHERE owner_type = 'user'
  AND owner_id = $current_user_id
  AND deleted_at IS NULL
ORDER BY created_at DESC;

-- 查询协作项目
SELECT p.* FROM projects p
INNER JOIN project_members pm ON p.project_id = pm.project_id
WHERE pm.user_id = $current_user_id
  AND pm.role != 'owner'
  AND p.deleted_at IS NULL
ORDER BY p.created_at DESC;

6.2 V2 查询

-- 查询企业的所有项目
SELECT * FROM projects
WHERE owner_type = 'organization'
  AND owner_id = $organization_id
  AND deleted_at IS NULL
ORDER BY created_at DESC;

-- 查询有预算的项目
SELECT * FROM projects
WHERE ai_credits_budget > 0
  AND deleted_at IS NULL
ORDER BY ai_credits_budget DESC;

-- 查询预算即将用完的项目(剩余 < 10%)
SELECT
    project_id,
    name,
    ai_credits_budget,
    budget_consumed,
    (ai_credits_budget - budget_consumed) AS remaining,
    ROUND((budget_consumed::NUMERIC / NULLIF(ai_credits_budget, 0)) * 100, 2) AS usage_percent
FROM projects
WHERE ai_credits_budget > 0
  AND deleted_at IS NULL
  AND (budget_consumed::NUMERIC / NULLIF(ai_credits_budget, 0)) > 0.9
ORDER BY usage_percent DESC;

7. 性能优化

7.1 索引策略

  1. 复合索引(owner_type, owner_id) 支持按所有者查询
  2. 部分索引:只索引未删除的记录,减少索引大小
  3. GIN 索引:支持 JSONB 字段和全文搜索
  4. 预算索引:只索引有预算的项目(ai_credits_budget > 0

7.2 查询优化

-- 使用 EXPLAIN ANALYZE 分析查询性能
EXPLAIN ANALYZE
SELECT * FROM projects
WHERE owner_type = 'user'
  AND owner_id = 123
  AND deleted_at IS NULL;

-- 预期使用索引:idx_projects_owner

8. 优势总结

8.1 技术优势

  1. 扩展性强:多态所有者模式,轻松支持新类型
  2. 前期简单:V1 只用个人用户,复杂度低
  3. 平滑升级:V2 引入企业无需重构数据
  4. 性能优秀:索引优化,查询高效
  5. 审计完整:预留字段,便于追踪

8.2 业务优势

  1. 灵活计费:支持个人付费和企业付费
  2. 预算控制:企业可以为项目设置预算上限
  3. 成本透明:项目消耗清晰可见
  4. 协作友好:个人在企业项目中工作,使用企业积分

9. 注意事项

9.1 V1 阶段

  1. 字段约束

    • owner_type 必须为 'user'
    • ai_credits_budgetbudget_consumed 必须为 0
  2. 业务逻辑

    • 创建项目时自动设置 owner_type='user'
    • 积分消耗只从用户账户扣除
  3. 数据验证

    • 应用层校验 owner_type 只能是 'user'
    • 数据库层使用 CHECK 约束保证数据一致性

9.2 V2 升级

  1. 数据迁移

    • 现有项目无需迁移(owner_type 已经是 'user')
    • 只需创建 organizations 表
  2. 代码升级

    • 修改创建项目逻辑,支持 owner_type='organization'
    • 修改积分消耗逻辑,支持项目预算优先
  3. 测试验证

    • 测试企业项目创建
    • 测试项目预算充值
    • 测试积分消耗优先级

10. 相关文档


方案版本:v1.0
最后更新:2025-01-14