门店档案模块.md 61 KB

门店档案模块设计文档

本文档为门店档案功能的完整需求设计,涵盖数据库设计、实体类、枚举、API 接口、OSS 文件上传方案。 门店档案用于管理门店的基础信息、固定资产、合同信息、结算开票信息及合同附件。


一、功能概述

1.1 业务背景

现有 t_shop 表仅包含门店基础字段(名称、地址、联系人等),无法满足业务上对门店合同、租金、结算、附件等档案信息的管理需求。本模块在现有门店体系基础上扩展,新增门店档案主表、固定资产子表、附件子表,实现门店全生命周期档案管理。

1.2 与现有模块的关系

现有模块 关系说明
t_shop(门店表) 门店档案通过 shop_id 关联,一个门店对应一条档案记录
t_device(设备表) 固定资产中的"自助售卖机"与设备表独立管理,档案中的设备信息侧重商务属性(机器编号、状态),不替代设备表的技术属性
OssUploadController 新增 OSS 上传凭证接口,前端获取 STS Token 后直传 OSS,后端仅保存 URL

1.3 模块划分

门店档案
├── 一、门店基础信息(档案主表字段)
├── 二、固定资产设备信息(固定资产子表,1:N)
├── 三、合同核心信息(档案主表字段)
├── 四、甲方收款结算 & 开票信息(档案主表字段)
├── 五、合同附件归档(附件子表,1:N)
└── 六、系统溯源字段(自动回填)

二、数据库设计

2.1 门店档案主表 t_shop_archive

CREATE TABLE IF NOT EXISTS `t_shop_archive` (
    `id` BIGINT NOT NULL COMMENT '主键ID(雪花算法)',

    -- ========== 关联 ==========
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID(t_shop.id)',

    -- ========== 一、门店基础信息 ==========
    `shop_name` VARCHAR(100) NOT NULL COMMENT '门店名称(冗余,便于查询)',
    `shop_address` VARCHAR(255) DEFAULT NULL COMMENT '门店地址(完整详细地址)',
    `venue_type` VARCHAR(20) DEFAULT NULL COMMENT '合作场地类型:CHARGING_STATION-充电站, OFFICE_BUILDING-写字楼',
    `cooperation_mode` VARCHAR(100) DEFAULT NULL COMMENT '合作模式(多选逗号分隔):DRINKS-水饮副食, BOX_MEAL-盒饭',

    `party_b_salesman` VARCHAR(50) DEFAULT NULL COMMENT '乙方业务员姓名',
    `party_b_salesman_phone` VARCHAR(20) DEFAULT NULL COMMENT '乙方业务员手机号',

    `party_a_company` VARCHAR(200) DEFAULT NULL COMMENT '甲方公司名称(签约主体全称)',
    `party_a_manager` VARCHAR(50) DEFAULT NULL COMMENT '甲方场地管理人员姓名',
    `party_a_manager_phone` VARCHAR(20) DEFAULT NULL COMMENT '甲方场地管理人员电话',

    `business_status` VARCHAR(20) DEFAULT 'NORMAL' COMMENT '门店经营状态:NORMAL-正常营业, SUSPENDED-暂停营业, WITHDRAWN-已撤场',
    `remark` VARCHAR(500) DEFAULT NULL COMMENT '门店备注(特殊要求等)',

    -- ========== 三、合同核心信息 ==========
    `contract_no` VARCHAR(100) DEFAULT NULL COMMENT '合同编号(企业归档唯一编码)',
    `contract_sign_date` DATE DEFAULT NULL COMMENT '合同签订日期',
    `contract_start_date` DATE DEFAULT NULL COMMENT '合同起始日',
    `contract_end_date` DATE DEFAULT NULL COMMENT '合同到期日',
    `contract_status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '合同状态:PENDING-待签约, ACTIVE-已生效, EXPIRING-到期待续约, TERMINATED-已解约终止',

    `rent_start_date` DATE DEFAULT NULL COMMENT '租金起始日',
    `monthly_rent` DECIMAL(10,2) DEFAULT NULL COMMENT '每月固定租金金额(元)',
    `rent_free_days` INT DEFAULT 0 COMMENT '免租期天数',
    `rent_pay_deadline_day` INT DEFAULT NULL COMMENT '租金缴纳截止日(每月几号)',

    `deposit_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '保证金(合同约定总额,元)',
    `deposit_status` VARCHAR(20) DEFAULT 'UNPAID' COMMENT '保证金缴纳状态:UNPAID-未缴纳, PARTIAL-部分缴纳, FULL-全额缴纳',
    `deposit_paid_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '保证金实缴金额(元)',
    `deposit_refund_condition` VARCHAR(500) DEFAULT NULL COMMENT '保证金退还条件',

    `billing_mode` VARCHAR(20) DEFAULT 'RENT_ONLY' COMMENT '合作计费模式:RENT_ONLY-纯租金, RENT_ELECTRIC-租金+电费, COMMISSION_ONLY-纯提成, RENT_COMMISSION-租金+提成, RENT_COMMISSION_ELECTRIC-租金+提成+电费',
    `electricity_price` DECIMAL(8,4) DEFAULT NULL COMMENT '电费结算单价(元/度)',
    `electricity_share_rule` VARCHAR(500) DEFAULT NULL COMMENT '电费公摊规则说明',

    `commission_base_type` VARCHAR(20) DEFAULT NULL COMMENT '提成基数计算口径:SALES-门店实收销售额, GROSS_PROFIT-门店毛利总额, NET_REVENUE-扣除成本后营收',
    `commission_base` DECIMAL(12,2) DEFAULT NULL COMMENT '提成基数(元)',
    `commission_rate` DECIMAL(5,2) DEFAULT NULL COMMENT '提成比例(0~100,单位%)',
    `commission_min_guarantee` DECIMAL(10,2) DEFAULT NULL COMMENT '最低保底提成金额(元)',
    `commission_period_months` INT DEFAULT 0 COMMENT '提成账期(延后N个月结算)',

    `payment_cycle` VARCHAR(20) DEFAULT 'MONTHLY' COMMENT '计费方式:MONTHLY-月付, QUARTERLY-季度付, SEMI_ANNUAL-半年付, ANNUAL-年付',

    `termination_date` DATE DEFAULT NULL COMMENT '解约日期',
    `termination_remark` VARCHAR(500) DEFAULT NULL COMMENT '解约备注',

    -- ========== 四、甲方收款结算 & 开票信息 ==========
    `payment_method` VARCHAR(20) DEFAULT NULL COMMENT '打款方式:CORPORATE-对公, PRIVATE-对私',
    `payee_account_name` VARCHAR(100) DEFAULT NULL COMMENT '甲方收款账户名称',
    `payee_bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款开户行',
    `payee_bank_account` VARCHAR(50) DEFAULT NULL COMMENT '银行账号',
    `taxpayer_id` VARCHAR(50) DEFAULT NULL COMMENT '纳税人识别号',
    `invoice_type` VARCHAR(20) DEFAULT NULL COMMENT '发票类型:SPECIAL-增值税专票, NORMAL-增值税普票',
    `invoice_tax_rate` DECIMAL(5,2) DEFAULT NULL COMMENT '开票税率(单位%)',

    -- ========== 六、系统溯源字段 ==========
    `creator_id` BIGINT DEFAULT NULL COMMENT '数据创建人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '数据创建人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑人ID',
    `editor_name` VARCHAR(50) DEFAULT NULL COMMENT '最后编辑人姓名',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_shop_id` (`shop_id`),
    KEY `idx_contract_no` (`contract_no`),
    KEY `idx_contract_status` (`contract_status`),
    KEY `idx_business_status` (`business_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店档案主表';

2.2 固定资产设备表 t_shop_fixed_asset

CREATE TABLE IF NOT EXISTS `t_shop_fixed_asset` (
    `id` BIGINT NOT NULL COMMENT '主键ID(雪花算法)',

    `shop_archive_id` BIGINT NOT NULL COMMENT '关联门店档案ID(t_shop_archive.id)',
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID(冗余,便于查询)',

    `asset_type` VARCHAR(20) NOT NULL COMMENT '资产类型:VENDING_MACHINE-自助售卖机, MICROWAVE-微波炉',
    `asset_name` VARCHAR(100) DEFAULT NULL COMMENT '资产名称(如售卖机名称)',
    `asset_code` VARCHAR(100) DEFAULT NULL COMMENT '资产编号(机器编号/微波炉编号)',
    `asset_status` VARCHAR(20) DEFAULT 'NORMAL' COMMENT '设备当前状态:NORMAL-正常运行, REPAIR-故障维修, IDLE-闲置撤机',

    `remark` VARCHAR(200) DEFAULT NULL COMMENT '备注',

    `creator_id` BIGINT DEFAULT NULL COMMENT '创建人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑人ID',
    `editor_name` VARCHAR(50) DEFAULT NULL COMMENT '最后编辑人姓名',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',

    PRIMARY KEY (`id`),
    KEY `idx_shop_archive_id` (`shop_archive_id`),
    KEY `idx_shop_id` (`shop_id`),
    KEY `idx_asset_type` (`asset_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店固定资产设备表';

2.3 合同附件表 t_shop_contract_attachment

CREATE TABLE IF NOT EXISTS `t_shop_contract_attachment` (
    `id` BIGINT NOT NULL COMMENT '主键ID(雪花算法)',

    `shop_archive_id` BIGINT NOT NULL COMMENT '关联门店档案ID(t_shop_archive.id)',
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID(冗余,便于查询)',

    `attachment_type` VARCHAR(30) NOT NULL COMMENT '附件类型:CONTRACT-合同附件, LEASE_AGREEMENT-场地租赁协议, ELECTRICITY_AGREEMENT-电费补充协议, DEPOSIT_RECEIPT-保证金收据, TERMINATION_AGREEMENT-解约/补充变更协议',
    `file_name` VARCHAR(200) NOT NULL COMMENT '原始文件名',
    `file_url` VARCHAR(500) NOT NULL COMMENT 'OSS文件URL',
    `file_size` BIGINT DEFAULT NULL COMMENT '文件大小(字节)',
    `file_extension` VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名(pdf, jpg, png, docx等)',

    `sort_order` INT DEFAULT 0 COMMENT '排序序号',

    `creator_id` BIGINT DEFAULT NULL COMMENT '上传人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '上传人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',

    PRIMARY KEY (`id`),
    KEY `idx_shop_archive_id` (`shop_archive_id`),
    KEY `idx_shop_id` (`shop_id`),
    KEY `idx_attachment_type` (`attachment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店合同附件表';

三、枚举类设计

3.1 合作场地类型 VenueType

位置: haha-common/src/main/java/com/haha/common/enums/VenueType.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum VenueType {

    CHARGING_STATION("CHARGING_STATION", "充电站"),
    OFFICE_BUILDING("OFFICE_BUILDING", "写字楼");

    private final String code;
    private final String description;

    VenueType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static VenueType fromCode(String code) {
        if (code == null) return null;
        for (VenueType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        VenueType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}

3.2 门店经营状态 BusinessStatus

位置: haha-common/src/main/java/com/haha/common/enums/BusinessStatus.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum BusinessStatus {

    NORMAL("NORMAL", "正常营业"),
    SUSPENDED("SUSPENDED", "暂停营业"),
    WITHDRAWN("WITHDRAWN", "已撤场");

    private final String code;
    private final String description;

    BusinessStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static BusinessStatus fromCode(String code) {
        if (code == null) return null;
        for (BusinessStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        BusinessStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}

3.3 合同状态 ContractStatus

位置: haha-common/src/main/java/com/haha/common/enums/ContractStatus.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum ContractStatus {

    PENDING("PENDING", "待签约"),
    ACTIVE("ACTIVE", "已生效"),
    EXPIRING("EXPIRING", "到期待续约"),
    TERMINATED("TERMINATED", "已解约终止");

    private final String code;
    private final String description;

    ContractStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static ContractStatus fromCode(String code) {
        if (code == null) return null;
        for (ContractStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        ContractStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}

3.4 保证金缴纳状态 DepositStatus

位置: haha-common/src/main/java/com/haha/common/enums/DepositStatus.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum DepositStatus {

    UNPAID("UNPAID", "未缴纳"),
    PARTIAL("PARTIAL", "部分缴纳"),
    FULL("FULL", "全额缴纳");

    private final String code;
    private final String description;

    DepositStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static DepositStatus fromCode(String code) {
        if (code == null) return null;
        for (DepositStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        DepositStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}

3.5 计费模式 BillingMode

位置: haha-common/src/main/java/com/haha/common/enums/BillingMode.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum BillingMode {

    RENT_ONLY("RENT_ONLY", "纯租金"),
    RENT_ELECTRIC("RENT_ELECTRIC", "租金+电费"),
    COMMISSION_ONLY("COMMISSION_ONLY", "纯提成"),
    RENT_COMMISSION("RENT_COMMISSION", "租金+提成"),
    RENT_COMMISSION_ELECTRIC("RENT_COMMISSION_ELECTRIC", "租金+提成+电费");

    private final String code;
    private final String description;

    BillingMode(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static BillingMode fromCode(String code) {
        if (code == null) return null;
        for (BillingMode m : values()) {
            if (m.code.equals(code)) return m;
        }
        return null;
    }

    public static String getDescription(String code) {
        BillingMode m = fromCode(code);
        return m != null ? m.description : "未知";
    }
}

3.6 计费方式 PaymentCycle

位置: haha-common/src/main/java/com/haha/common/enums/PaymentCycle.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum PaymentCycle {

    MONTHLY("MONTHLY", "月付"),
    QUARTERLY("QUARTERLY", "季度付"),
    SEMI_ANNUAL("SEMI_ANNUAL", "半年付"),
    ANNUAL("ANNUAL", "年付");

    private final String code;
    private final String description;

    PaymentCycle(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static PaymentCycle fromCode(String code) {
        if (code == null) return null;
        for (PaymentCycle c : values()) {
            if (c.code.equals(code)) return c;
        }
        return null;
    }

    public static String getDescription(String code) {
        PaymentCycle c = fromCode(code);
        return c != null ? c.description : "未知";
    }
}

3.7 固定资产类型 AssetType

位置: haha-common/src/main/java/com/haha/common/enums/AssetType.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AssetType {

    VENDING_MACHINE("VENDING_MACHINE", "自助售卖机"),
    MICROWAVE("MICROWAVE", "微波炉");

    private final String code;
    private final String description;

    AssetType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AssetType fromCode(String code) {
        if (code == null) return null;
        for (AssetType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        AssetType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}

3.8 资产设备状态 AssetStatus

位置: haha-common/src/main/java/com/haha/common/enums/AssetStatus.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AssetStatus {

    NORMAL("NORMAL", "正常运行"),
    REPAIR("REPAIR", "故障维修"),
    IDLE("IDLE", "闲置撤机");

    private final String code;
    private final String description;

    AssetStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AssetStatus fromCode(String code) {
        if (code == null) return null;
        for (AssetStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        AssetStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}

3.9 附件类型 AttachmentType

位置: haha-common/src/main/java/com/haha/common/enums/AttachmentType.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AttachmentType {

    CONTRACT("CONTRACT", "合同附件"),
    LEASE_AGREEMENT("LEASE_AGREEMENT", "场地租赁协议"),
    ELECTRICITY_AGREEMENT("ELECTRICITY_AGREEMENT", "电费补充协议"),
    DEPOSIT_RECEIPT("DEPOSIT_RECEIPT", "保证金收据"),
    TERMINATION_AGREEMENT("TERMINATION_AGREEMENT", "解约/补充变更协议");

    private final String code;
    private final String description;

    AttachmentType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AttachmentType fromCode(String code) {
        if (code == null) return null;
        for (AttachmentType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        AttachmentType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}

3.10 打款方式 PaymentMethod

位置: haha-common/src/main/java/com/haha/common/enums/PaymentMethod.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum PaymentMethod {

    CORPORATE("CORPORATE", "对公"),
    PRIVATE("PRIVATE", "对私");

    private final String code;
    private final String description;

    PaymentMethod(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static PaymentMethod fromCode(String code) {
        if (code == null) return null;
        for (PaymentMethod m : values()) {
            if (m.code.equals(code)) return m;
        }
        return null;
    }

    public static String getDescription(String code) {
        PaymentMethod m = fromCode(code);
        return m != null ? m.description : "未知";
    }
}

3.11 发票类型 InvoiceType

位置: haha-common/src/main/java/com/haha/common/enums/InvoiceType.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum InvoiceType {

    SPECIAL("SPECIAL", "增值税专票"),
    NORMAL("NORMAL", "增值税普票");

    private final String code;
    private final String description;

    InvoiceType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static InvoiceType fromCode(String code) {
        if (code == null) return null;
        for (InvoiceType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        InvoiceType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}

3.12 提成基数计算口径 CommissionBaseType

位置: haha-common/src/main/java/com/haha/common/enums/CommissionBaseType.java

package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum CommissionBaseType {

    SALES("SALES", "门店实收销售额"),
    GROSS_PROFIT("GROSS_PROFIT", "门店毛利总额"),
    NET_REVENUE("NET_REVENUE", "扣除成本后营收");

    private final String code;
    private final String description;

    CommissionBaseType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static CommissionBaseType fromCode(String code) {
        if (code == null) return null;
        for (CommissionBaseType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        CommissionBaseType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}

四、实体类设计

4.1 ShopArchive(门店档案主表实体)

位置: haha-entity/src/main/java/com/haha/entity/ShopArchive.java

package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@Data
@TableName("t_shop_archive")
public class ShopArchive implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    // ========== 关联 ==========
    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    // ========== 一、门店基础信息 ==========
    private String shopName;
    private String shopAddress;
    private String venueType;
    private String cooperationMode;
    private String partyBSalesman;
    private String partyBSalesmanPhone;
    private String partyACompany;
    private String partyAManager;
    private String partyAManagerPhone;
    private String businessStatus;
    private String remark;

    // ========== 三、合同核心信息 ==========
    private String contractNo;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractSignDate;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractStartDate;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractEndDate;

    private String contractStatus;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate rentStartDate;

    private BigDecimal monthlyRent;
    private Integer rentFreeDays;
    private Integer rentPayDeadlineDay;

    private BigDecimal depositAmount;
    private String depositStatus;
    private BigDecimal depositPaidAmount;
    private String depositRefundCondition;

    private String billingMode;
    private BigDecimal electricityPrice;
    private String electricityShareRule;

    private String commissionBaseType;
    private BigDecimal commissionBase;
    private BigDecimal commissionRate;
    private BigDecimal commissionMinGuarantee;
    private Integer commissionPeriodMonths;

    private String paymentCycle;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate terminationDate;

    private String terminationRemark;

    // ========== 四、甲方收款结算 & 开票信息 ==========
    private String paymentMethod;
    private String payeeAccountName;
    private String payeeBankName;
    private String payeeBankAccount;
    private String taxpayerId;
    private String invoiceType;
    private BigDecimal invoiceTaxRate;

    // ========== 六、系统溯源字段 ==========
    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long editorId;
    private String editorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime updateTime;

    // ========== 非数据库字段 ==========

    /** 关联的固定资产列表 */
    @TableField(exist = false)
    private List<ShopFixedAsset> fixedAssets;

    /** 关联的附件列表 */
    @TableField(exist = false)
    private List<ShopContractAttachment> attachments;

    /** 关联门店编码 */
    @TableField(exist = false)
    private String shopCode;

    /** 合同状态标签 */
    @TableField(exist = false)
    private String contractStatusLabel;

    /** 经营状态标签 */
    @TableField(exist = false)
    private String businessStatusLabel;
}

4.2 ShopFixedAsset(固定资产设备实体)

位置: haha-entity/src/main/java/com/haha/entity/ShopFixedAsset.java

package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@TableName("t_shop_fixed_asset")
public class ShopFixedAsset implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopArchiveId;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    private String assetType;
    private String assetName;
    private String assetCode;
    private String assetStatus;
    private String remark;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long editorId;
    private String editorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime updateTime;

    // ========== 非数据库字段 ==========

    /** 资产类型标签 */
    @TableField(exist = false)
    private String assetTypeLabel;

    /** 资产状态标签 */
    @TableField(exist = false)
    private String assetStatusLabel;
}

4.3 ShopContractAttachment(合同附件实体)

位置: haha-entity/src/main/java/com/haha/entity/ShopContractAttachment.java

package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@TableName("t_shop_contract_attachment")
public class ShopContractAttachment implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopArchiveId;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    private String attachmentType;
    private String fileName;
    private String fileUrl;
    private Long fileSize;
    private String fileExtension;
    private Integer sortOrder;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    // ========== 非数据库字段 ==========

    /** 附件类型标签 */
    @TableField(exist = false)
    private String attachmentTypeLabel;
}

五、DTO 类设计

5.1 ShopArchiveCreateDTO

位置: haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveCreateDTO.java

package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Data
public class ShopArchiveCreateDTO {

    @NotNull(message = "门店ID不能为空")
    private Long shopId;

    // 基础信息
    @NotBlank(message = "门店名称不能为空")
    private String shopName;
    private String shopAddress;
    private String venueType;
    private String cooperationMode;
    private String partyBSalesman;
    private String partyBSalesmanPhone;
    private String partyACompany;
    private String partyAManager;
    private String partyAManagerPhone;
    private String businessStatus;
    private String remark;

    // 合同信息
    private String contractNo;
    private LocalDate contractSignDate;
    private LocalDate contractStartDate;
    private LocalDate contractEndDate;
    private String contractStatus;
    private LocalDate rentStartDate;
    private BigDecimal monthlyRent;
    private Integer rentFreeDays;
    private Integer rentPayDeadlineDay;
    private BigDecimal depositAmount;
    private String depositStatus;
    private BigDecimal depositPaidAmount;
    private String depositRefundCondition;
    private String billingMode;
    private BigDecimal electricityPrice;
    private String electricityShareRule;
    private String commissionBaseType;
    private BigDecimal commissionBase;
    private BigDecimal commissionRate;
    private BigDecimal commissionMinGuarantee;
    private Integer commissionPeriodMonths;
    private String paymentCycle;
    private LocalDate terminationDate;
    private String terminationRemark;

    // 结算开票信息
    private String paymentMethod;
    private String payeeAccountName;
    private String payeeBankName;
    private String payeeBankAccount;
    private String taxpayerId;
    private String invoiceType;
    private BigDecimal invoiceTaxRate;

    // 固定资产列表
    @Valid
    private List<FixedAssetDTO> fixedAssets;

    // 附件列表
    @Valid
    private List<AttachmentDTO> attachments;
}

5.2 ShopArchiveUpdateDTO

位置: haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveUpdateDTO.java

ShopArchiveCreateDTO 字段相同,额外增加:

@NotNull(message = "档案ID不能为空")
private Long id;

5.3 ShopArchiveQueryDTO

位置: haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveQueryDTO.java

package com.haha.entity.dto;

import com.haha.common.dto.BasePageQueryDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class ShopArchiveQueryDTO extends BasePageQueryDTO {

    private Long shopId;
    private String shopName;
    private String contractNo;
    private String contractStatus;
    private String businessStatus;
    private String billingMode;
    private String venueType;
}

5.4 FixedAssetDTO(固定资产子项DTO)

位置: haha-entity/src/main/java/com/haha/entity/dto/FixedAssetDTO.java

package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.constraints.NotBlank;

@Data
public class FixedAssetDTO {

    private Long id;  // 更新时传,新增时不传

    @NotBlank(message = "资产类型不能为空")
    private String assetType;

    private String assetName;
    private String assetCode;
    private String assetStatus;
    private String remark;
}

5.5 AttachmentDTO(附件子项DTO)

位置: haha-entity/src/main/java/com/haha/entity/dto/AttachmentDTO.java

package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.constraints.NotBlank;

@Data
public class AttachmentDTO {

    private Long id;  // 更新时传,新增时不传

    @NotBlank(message = "附件类型不能为空")
    private String attachmentType;

    @NotBlank(message = "文件名不能为空")
    private String fileName;

    @NotBlank(message = "文件URL不能为空")
    private String fileUrl;

    private Long fileSize;
    private String fileExtension;
    private Integer sortOrder;
}

六、Mapper 接口设计

6.1 ShopArchiveMapper

位置: haha-mapper/src/main/java/com/haha/mapper/ShopArchiveMapper.java

package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopArchive;
import org.apache.ibatis.annotations.Param;

public interface ShopArchiveMapper extends BaseMapper<ShopArchive> {

    /**
     * 根据门店ID查询档案(含关联数据)
     */
    ShopArchive selectByShopId(@Param("shopId") Long shopId);
}

6.2 ShopFixedAssetMapper

位置: haha-mapper/src/main/java/com/haha/mapper/ShopFixedAssetMapper.java

package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopFixedAsset;

public interface ShopFixedAssetMapper extends BaseMapper<ShopFixedAsset> {
}

6.3 ShopContractAttachmentMapper

位置: haha-mapper/src/main/java/com/haha/mapper/ShopContractAttachmentMapper.java

package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopContractAttachment;

public interface ShopContractAttachmentMapper extends BaseMapper<ShopContractAttachment> {
}

6.4 ShopArchiveMapper.xml

位置: haha-mapper/src/main/resources/mapper/ShopArchiveMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.haha.mapper.ShopArchiveMapper">

    <resultMap id="ShopArchiveResultMap" type="com.haha.entity.ShopArchive">
        <id column="id" property="id"/>
        <result column="shop_id" property="shopId"/>
        <result column="shop_name" property="shopName"/>
        <result column="shop_address" property="shopAddress"/>
        <result column="venue_type" property="venueType"/>
        <result column="cooperation_mode" property="cooperationMode"/>
        <result column="party_b_salesman" property="partyBSalesman"/>
        <result column="party_b_salesman_phone" property="partyBSalesmanPhone"/>
        <result column="party_a_company" property="partyACompany"/>
        <result column="party_a_manager" property="partyAManager"/>
        <result column="party_a_manager_phone" property="partyAManagerPhone"/>
        <result column="business_status" property="businessStatus"/>
        <result column="remark" property="remark"/>
        <result column="contract_no" property="contractNo"/>
        <result column="contract_sign_date" property="contractSignDate"/>
        <result column="contract_start_date" property="contractStartDate"/>
        <result column="contract_end_date" property="contractEndDate"/>
        <result column="contract_status" property="contractStatus"/>
        <result column="rent_start_date" property="rentStartDate"/>
        <result column="monthly_rent" property="monthlyRent"/>
        <result column="rent_free_days" property="rentFreeDays"/>
        <result column="rent_pay_deadline_day" property="rentPayDeadlineDay"/>
        <result column="deposit_amount" property="depositAmount"/>
        <result column="deposit_status" property="depositStatus"/>
        <result column="deposit_paid_amount" property="depositPaidAmount"/>
        <result column="deposit_refund_condition" property="depositRefundCondition"/>
        <result column="billing_mode" property="billingMode"/>
        <result column="electricity_price" property="electricityPrice"/>
        <result column="electricity_share_rule" property="electricityShareRule"/>
        <result column="commission_base_type" property="commissionBaseType"/>
        <result column="commission_base" property="commissionBase"/>
        <result column="commission_rate" property="commissionRate"/>
        <result column="commission_min_guarantee" property="commissionMinGuarantee"/>
        <result column="commission_period_months" property="commissionPeriodMonths"/>
        <result column="payment_cycle" property="paymentCycle"/>
        <result column="termination_date" property="terminationDate"/>
        <result column="termination_remark" property="terminationRemark"/>
        <result column="payment_method" property="paymentMethod"/>
        <result column="payee_account_name" property="payeeAccountName"/>
        <result column="payee_bank_name" property="payeeBankName"/>
        <result column="payee_bank_account" property="payeeBankAccount"/>
        <result column="taxpayer_id" property="taxpayerId"/>
        <result column="invoice_type" property="invoiceType"/>
        <result column="invoice_tax_rate" property="invoiceTaxRate"/>
        <result column="creator_id" property="creatorId"/>
        <result column="creator_name" property="creatorName"/>
        <result column="create_time" property="createTime"/>
        <result column="editor_id" property="editorId"/>
        <result column="editor_name" property="editorName"/>
        <result column="update_time" property="updateTime"/>
    </resultMap>

    <select id="selectByShopId" resultMap="ShopArchiveResultMap">
        SELECT * FROM t_shop_archive
        WHERE shop_id = #{shopId}
        AND deleted = 0
        LIMIT 1
    </select>

</mapper>

七、Service 层设计

7.1 ShopArchiveService 接口

位置: haha-service/src/main/java/com/haha/service/ShopArchiveService.java

package com.haha.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.haha.entity.ShopArchive;
import com.haha.entity.dto.ShopArchiveCreateDTO;
import com.haha.entity.dto.ShopArchiveQueryDTO;
import com.haha.entity.dto.ShopArchiveUpdateDTO;

import java.util.List;

public interface ShopArchiveService extends IService<ShopArchive> {

    /**
     * 分页查询门店档案列表
     */
    IPage<ShopArchive> getPage(ShopArchiveQueryDTO queryDTO);

    /**
     * 查询门店档案详情(含固定资产、附件)
     */
    ShopArchive getDetailById(Long id);

    /**
     * 根据门店ID查询档案
     */
    ShopArchive getByShopId(Long shopId);

    /**
     * 创建门店档案
     */
    ShopArchive create(ShopArchiveCreateDTO dto);

    /**
     * 更新门店档案
     */
    ShopArchive update(ShopArchiveUpdateDTO dto);

    /**
     * 删除门店档案(逻辑删除)
     */
    void delete(Long id);

    /**
     * 获取所有枚举选项(供前端下拉使用)
     */
    List<?> getEnumOptions(String enumType);
}

7.2 ShopArchiveServiceImpl 核心逻辑

位置: haha-service/src/main/java/com/haha/service/impl/ShopArchiveServiceImpl.java

核心业务逻辑要点:

  1. 创建档案

    • 校验 shopId 对应的门店是否存在
    • 校验同一门店不可重复创建档案(uk_shop_id 唯一约束)
    • 自动回填 creatorIdcreatorName(从 Sa-Token 获取当前登录管理员)
    • 事务内保存主表 + 批量保存固定资产 + 批量保存附件
  2. 更新档案

    • 自动回填 editorIdeditorName
    • 固定资产采用"先删后增"策略(根据 shopArchiveId 删除旧记录,重新插入)
    • 附件采用"差异对比"策略(根据 ID 判断新增/更新/删除)
    • 提成比例校验:0~100 区间
  3. 删除档案

    • 逻辑删除(添加 deleted 字段,或在应用层标记状态)
    • 同步删除关联的固定资产和附件记录
  4. 条件显示逻辑(前端根据以下规则控制字段显隐):

    • billingMode 包含电费相关(RENT_ELECTRIC / RENT_COMMISSION_ELECTRIC)时,显示电费单价和公摊规则
    • billingMode 包含提成相关(COMMISSION_ONLY / RENT_COMMISSION / RENT_COMMISSION_ELECTRIC)时,显示提成基数、比例、保底金额、账期
    • paymentMethod = CORPORATE 时,开户行必填

八、Controller 层设计

位置: haha-admin/src/main/java/com/haha/admin/controller/ShopArchiveController.java

@Slf4j
@RestController
@RequestMapping("/shop-archive")
@RequiredArgsConstructor
public class ShopArchiveController {

    private final ShopArchiveService shopArchiveService;

    /**
     * 分页查询门店档案列表
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/list")
    public Result<PageResult<ShopArchive>> list(ShopArchiveQueryDTO queryDTO) {
        queryDTO.validate();
        return Result.success("查询成功", PageResult.of(shopArchiveService.getPage(queryDTO)));
    }

    /**
     * 查询门店档案详情
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/{id}")
    public Result<ShopArchive> getById(@PathVariable Long id) {
        ShopArchive archive = shopArchiveService.getDetailById(id);
        if (archive == null) {
            return Result.error(404, "门店档案不存在");
        }
        return Result.success("查询成功", archive);
    }

    /**
     * 根据门店ID查询档案
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/by-shop/{shopId}")
    public Result<ShopArchive> getByShopId(@PathVariable Long shopId) {
        ShopArchive archive = shopArchiveService.getByShopId(shopId);
        if (archive == null) {
            return Result.error(404, "该门店尚未创建档案");
        }
        return Result.success("查询成功", archive);
    }

    /**
     * 创建门店档案
     */
    @RequirePermission("shop:archive:create")
    @Log(module = "门店档案管理", operation = OperationType.CREATE, summary = "创建门店档案")
    @PostMapping
    public Result<ShopArchive> create(@RequestBody @Valid ShopArchiveCreateDTO dto) {
        ShopArchive archive = shopArchiveService.create(dto);
        return Result.success("创建成功", archive);
    }

    /**
     * 更新门店档案
     */
    @RequirePermission("shop:archive:update")
    @Log(module = "门店档案管理", operation = OperationType.UPDATE, summary = "更新门店档案")
    @PutMapping("/{id}")
    public Result<ShopArchive> update(@PathVariable Long id, @RequestBody @Valid ShopArchiveUpdateDTO dto) {
        dto.setId(id);
        ShopArchive archive = shopArchiveService.update(dto);
        return Result.success("更新成功", archive);
    }

    /**
     * 删除门店档案
     */
    @RequirePermission("shop:archive:delete")
    @Log(module = "门店档案管理", operation = OperationType.DELETE, summary = "删除门店档案")
    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        shopArchiveService.delete(id);
        return Result.success("删除成功", null);
    }

    /**
     * 获取枚举选项(供前端下拉框使用)
     * @param enumType 枚举类型:venueType / businessStatus / contractStatus / depositStatus / billingMode / paymentCycle / assetType / assetStatus / attachmentType / paymentMethod / invoiceType / commissionBaseType
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/enum-options")
    public Result<List<StatusLabel>> getEnumOptions(@RequestParam String enumType) {
        return Result.success("查询成功", shopArchiveService.getEnumOptions(enumType));
    }
}

8.1 API 接口汇总

操作 Method 路径 权限 说明
分页列表 GET /admin/shop-archive/list shop:archive:read 支持门店名称、合同编号、状态筛选
详情 GET /admin/shop-archive/{id} shop:archive:read 含固定资产、附件
按门店查询 GET /admin/shop-archive/by-shop/{shopId} shop:archive:read 根据门店ID查档案
创建 POST /admin/shop-archive shop:archive:create 创建完整档案
更新 PUT /admin/shop-archive/{id} shop:archive:update 更新档案信息
删除 DELETE /admin/shop-archive/{id} shop:archive:delete 逻辑删除
枚举选项 GET /admin/shop-archive/enum-options?enumType=xxx shop:archive:read 获取下拉选项
获取上传凭证 GET /admin/oss/upload-credential?dir=xxx shop:archive:create 返回 STS Token,前端直传 OSS
删除 OSS 文件 DELETE /admin/oss/file?objectKey=xxx shop:archive:delete 根据 objectKey 删除 OSS 文件

九、阿里云 OSS 文件上传方案(前端直传)

9.1 方案说明

采用前端直传 OSS 模式,文件不经过后端服务器中转:

──────────┐     ①请求上传凭证      ┌──────────┐
│  前端     │ ──────────────────────→ │  后端     │
│          │ ←────────────────────── │          │
│          │     ②返回STS Token       │          │
│          │                          │          │
│          │     ③直传文件到OSS        │          │
│          │ ──────────────────────→ 阿里云 OSS  │
│          │                          │          │
│          │     ④上传成功,回调后端    │          │
│          │ ──────────────────────→ │  后端     │
│          │     保存 fileUrl         │  保存URL  │
└──────────┘                          └──────────┘

优势

  • 后端不处理文件流,减轻服务器带宽和存储压力
  • 前端直传速度更快,用户体验更好
  • 通过 STS 临时凭证控制权限,安全性有保障

9.2 依赖引入

haha-service/pom.xml 中添加:

<!-- 阿里云 STS SDK(用于生成临时上传凭证) -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-sts</artifactId>
    <version>3.1.2</version>
</dependency>
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
    <version>4.6.4</version>
</dependency>

9.3 配置项

application.yml 中添加:

# 阿里云 OSS 配置
aliyun:
  oss:
    endpoint: oss-cn-hangzhou.aliyuncs.com                    # OSS Endpoint
    access-key-id: ${OSS_ACCESS_KEY_ID}                        # 主账号AK(仅用于签发STS)
    access-key-secret: ${OSS_ACCESS_KEY_SECRET}                # 主账号SK(仅用于签发STS)
    bucket-name: haha-shop-archive                             # Bucket 名称
    domain-prefix: https://haha-shop-archive.oss-cn-hangzhou.aliyuncs.com  # 访问域名前缀
    sts:
      role-arn: acs:ram::xxx:role/haha-oss-upload-role         # RAM角色ARN
      role-session-name: haha-shop-archive-upload              # 会话名称
      duration-seconds: 900                                    # 凭证有效期(秒),默认15分钟

9.4 OSS 配置类

位置: haha-common/src/main/java/com/haha/common/config/OssConfig.java

package com.haha.common.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration(proxyBeanMethods = false)
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssConfig {

    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;
    private String domainPrefix;
    private StsConfig sts = new StsConfig();

    @Data
    public static class StsConfig {
        private String roleArn;
        private String roleSessionName;
        private int durationSeconds = 900;
    }
}

9.5 OssService 接口与实现

接口位置: haha-service/src/main/java/com/haha/service/OssService.java

package com.haha.service;

import java.util.Map;

public interface OssService {

    /**
     * 获取前端直传 OSS 的临时凭证(STS Token)
     *
     * @param dir 上传目录前缀(如 "shop-archive/contract")
     * @return 包含 accessKeyId、accessKeySecret、securityToken、
     *         expiration、host、dir 的凭证信息
     */
    Map<String, String> getUploadCredential(String dir);

    /**
     * 根据 objectKey 删除 OSS 文件
     *
     * @param objectKey OSS 对象键(不含域名)
     */
    void deleteByObjectKey(String objectKey);
}

实现位置: haha-service/src/main/java/com/haha/service/impl/OssServiceImpl.java

核心逻辑:

  • getUploadCredential():调用阿里云 STS AssumeRole 接口,生成临时凭证
    • 返回 accessKeyIdaccessKeySecretsecurityTokenexpiration(过期时间)
    • 返回 host(bucket 上传地址,如 https://haha-shop-archive.oss-cn-hangzhou.aliyuncs.com
    • 返回 dir(上传目录,如 shop-archive/contract/20260709/
    • 前端拿到凭证后使用阿里云 OSS SDK(ali-oss npm 包)直传
  • 凭证有效期 15 分钟,过期后前端需重新请求
  • 前端上传成功后,将 fileUrldomainPrefix + objectKey)提交给后端保存

9.6 上传凭证 Controller

位置: haha-admin/src/main/java/com/haha/admin/controller/OssUploadController.java

@Slf4j
@RestController
@RequestMapping("/oss")
@RequiredArgsConstructor
public class OssUploadController {

    private final OssService ossService;

    /**
     * 获取 OSS 直传凭证
     *
     * @param dir 上传目录(如 contract、lease、electricity、deposit、termination)
     * @return STS 临时凭证 + 上传地址
     */
    @RequirePermission("shop:archive:create")
    @GetMapping("/upload-credential")
    public Result<Map<String, String>> getUploadCredential(
            @RequestParam(value = "dir", defaultValue = "shop-archive") String dir) {
        Map<String, String> credential = ossService.getUploadCredential(dir);
        return Result.success("获取凭证成功", credential);
    }

    /**
     * 删除 OSS 文件(根据 objectKey)
     */
    @RequirePermission("shop:archive:delete")
    @DeleteMapping("/file")
    public Result<Void> deleteFile(@RequestParam String objectKey) {
        ossService.deleteByObjectKey(objectKey);
        return Result.success("删除成功", null);
    }
}

9.7 前端上传方案

9.7.1 安装依赖

npm install ali-oss

9.7.2 上传工具类

位置: haha-admin-web/src/utils/ossUpload.ts

import OSS from 'ali-oss';
import { http } from '@/utils/http';

interface OssCredential {
  accessKeyId: string;
  accessKeySecret: string;
  securityToken: string;
  expiration: string;
  host: string;
  dir: string;
}

interface UploadResult {
  url: string;       // 完整访问URL(domainPrefix + objectKey)
  objectKey: string; // OSS对象键
  fileName: string;
  fileSize: number;
  fileExtension: string;
}

let cachedCredential: OssCredential | null = null;
let credentialExpireTime = 0;

/**
 * 获取 OSS 上传凭证(带缓存,过期前60秒自动刷新)
 */
async function getOssCredential(dir: string): Promise<OssCredential> {
  const now = Date.now();
  if (cachedCredential && credentialExpireTime - now > 60000) {
    return cachedCredential;
  }

  const res: any = await http.request('get', '/oss/upload-credential', {
    params: { dir }
  });

  if (res.code !== 200) {
    throw new Error(res.message || '获取上传凭证失败');
  }

  cachedCredential = res.data;
  credentialExpireTime = new Date(res.data.expiration).getTime();
  return cachedCredential;
}

/**
 * 前端直传文件到 OSS
 *
 * @param file     待上传文件
 * @param dir      上传目录(如 "shop-archive/contract")
 * @param onProgress 上传进度回调 (percent: number) => void
 * @returns        上传结果(URL、objectKey等)
 */
export async function uploadToOss(
  file: File,
  dir: string = 'shop-archive',
  onProgress?: (percent: number) => void
): Promise<UploadResult> {
  const credential = await getOssCredential(dir);

  const client = new OSS({
    region: credential.host.match(/oss-(.+?)\./)?.[1] || 'cn-hangzhou',
    accessKeyId: credential.accessKeyId,
    accessKeySecret: credential.accessKeySecret,
    stsToken: credential.securityToken,
    bucket: credential.host.match(/\/\/(.+?)\./)?.[1] || '',
    endpoint: credential.host,
  });

  // 生成 objectKey:dir/yyyyMMdd/uuid.ext
  const ext = file.name.includes('.') ? file.name.substring(file.name.lastIndexOf('.')) : '';
  const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const uuid = Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
  const objectKey = `${credential.dir}${dateStr}/${uuid}${ext}`;

  const result = await client.put(objectKey, file, {
    progress: (p: number) => onProgress?.(Math.round(p * 100)),
  });

  // 拼接完整访问URL
  const domainPrefix = credential.host.replace('://', '://');
  const url = `${domainPrefix}/${objectKey}`;

  return {
    url,
    objectKey,
    fileName: file.name,
    fileSize: file.size,
    fileExtension: ext.replace('.', ''),
  };
}

9.7.3 前端 API 层

位置: haha-admin-web/src/api/shopArchive.ts

import { http } from '@/utils/http';
import { uploadToOss } from '@/utils/ossUpload';

// 获取 OSS 上传凭证
export function getOssUploadCredential(dir: string) {
  return http.request('get', '/oss/upload-credential', { params: { dir } });
}

// 前端直传 OSS(封装)
export { uploadToOss };

// 删除 OSS 文件
export function deleteOssFile(objectKey: string) {
  return http.request('delete', '/oss/file', { params: { objectKey } });
}

// 门店档案 CRUD
export function getArchiveList(params: any) {
  return http.request('get', '/shop-archive/list', { params });
}

export function getArchiveDetail(id: string | number) {
  return http.request('get', `/shop-archive/${id}`);
}

export function createArchive(data: any) {
  return http.request('post', '/shop-archive', { data });
}

export function updateArchive(id: string | number, data: any) {
  return http.request('put', `/shop-archive/${id}`, { data });
}

export function deleteArchive(id: string | number) {
  return http.request('delete', `/shop-archive/${id}`);
}

export function getEnumOptions(enumType: string) {
  return http.request('get', '/shop-archive/enum-options', { params: { enumType } });
}

9.7.4 前端附件上传组件使用示例

<template>
  <el-upload
    :http-request="handleUpload"
    :show-file-list="true"
    :before-upload="beforeUpload"
    multiple
  >
    <el-button type="primary">上传合同附件</el-button>
  </el-upload>
</template>

<script setup lang="tsx">
import { uploadToOss } from '@/api/shopArchive';

const handleUpload = async (options: any) => {
  const file = options.file;
  try {
    const result = await uploadToOss(file, 'shop-archive/contract', (percent) => {
      options.onProgress({ percent });
    });

    // 将上传结果加入附件列表
    attachmentList.value.push({
      attachmentType: 'CONTRACT',
      fileName: result.fileName,
      fileUrl: result.url,
      fileSize: result.fileSize,
      fileExtension: result.fileExtension,
    });

    options.onSuccess(result);
  } catch (err) {
    options.onError(err);
  }
};

const beforeUpload = (file: File) => {
  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    ElMessage.error('文件大小不能超过 10MB');
    return false;
  }
  const allowedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx'];
  const ext = file.name.split('.').pop()?.toLowerCase();
  if (!ext || !allowedTypes.includes(ext)) {
    ElMessage.error('仅支持 PDF、图片、Word 格式');
    return false;
  }
  return true;
};
</script>

9.8 附件分组上传

前端附件区域按 5 种类型分组,每种类型独立上传入口:

附件类型 枚举值 上传目录 说明
合同附件 CONTRACT shop-archive/contract 支持多文件
场地租赁协议 LEASE_AGREEMENT shop-archive/lease 支持多文件
电费补充协议 ELECTRICITY_AGREEMENT shop-archive/electricity 支持多文件
保证金收据 DEPOSIT_RECEIPT shop-archive/deposit 支持多文件
解约/补充变更协议 TERMINATION_AGREEMENT shop-archive/termination 支持多文件

十、菜单与权限配置

10.1 权限点

权限标识 说明
shop:archive:read 查看门店档案
shop:archive:create 创建门店档案
shop:archive:update 编辑门店档案
shop:archive:delete 删除门店档案

10.2 菜单结构建议

在现有"运营管理 > 门店管理"下新增子菜单:

运营管理
├── 门店管理(现有)
├── 门店档案管理(新增)
│   ├── 查看
│   ├── 创建
│   ├── 编辑
│   └── 删除
├── 设备管理(现有)
├── ...

十一、SQL 迁移脚本

位置: docs/database/create_shop_archive.sql

包含三张表的完整建表语句(见第二章),以及初始菜单/权限数据插入。


十二、前端页面设计要点

12.1 档案列表页

  • 表格列:门店名称、合同编号、合同状态(标签)、经营状态(标签)、计费模式、每月租金、创建时间、操作
  • 筛选条件:门店名称(模糊)、合同编号、合同状态(下拉)、经营状态(下拉)、计费模式(下拉)
  • 操作列:查看、编辑、删除

12.2 档案详情/编辑页

采用分步 Tab 或折叠面板形式,按六大模块组织:

┌─────────────────────────────────────────────┐
│  Tab1: 门店基础信息                           │
│  Tab2: 固定资产设备                           │
│  Tab3: 合同核心信息                           │
│  Tab4: 收款结算 & 开票                        │
│  Tab5: 合同附件                               │
└─────────────────────────────────────────────┘

字段联动规则

  • 选择"计费模式"后,动态显示/隐藏电费和提成相关字段
  • 选择"打款方式=对公"时,显示开户行字段
  • 合同状态为"已解约终止"时,显示解约日期和解约备注
  • 固定资产支持动态增删行(表格内编辑)
  • 附件按类型分组展示,每组支持多文件上传

12.3 路由配置

haha-admin-web/src/router/modules/operation.ts 中新增:

{
  path: "/operation/shop-archive",
  name: "ShopArchiveList",
  component: () => import("@/views/shop-archive/index.vue"),
  meta: {
    icon: "ri:file-text-line",
    title: "门店档案管理"
  }
}

十三、开发任务清单

Phase 1:基础搭建

  • 执行建表 SQL(t_shop_archivet_shop_fixed_assett_shop_contract_attachment
  • 新增 12 个枚举类(VenueType、BusinessStatus、ContractStatus、DepositStatus、BillingMode、PaymentCycle、AssetType、AssetStatus、AttachmentType、PaymentMethod、InvoiceType、CommissionBaseType)
  • 新增 3 个 Entity 类(ShopArchive、ShopFixedAsset、ShopContractAttachment)
  • 新增 3 个 Mapper 接口 + XML
  • 新增 DTO 类(ShopArchiveCreateDTO、ShopArchiveUpdateDTO、ShopArchiveQueryDTO、FixedAssetDTO、AttachmentDTO)

Phase 2:业务逻辑

  • 新增 OssConfig 配置类(haha-common)
  • 新增 OssService 接口与实现(STS 临时凭证签发 + 文件删除)
  • 新增 OssUploadController(获取凭证 + 删除文件)
  • 新增 ShopArchiveService 接口与实现
  • 新增 ShopArchiveController(7 个 API 接口)
  • 配置 OSS 相关 application.yml 配置项(AK/SK/STS Role)
  • 插入菜单和权限数据

Phase 3:前端开发

  • 安装 ali-oss npm 依赖
  • 新增 ossUpload.ts 上传工具类(凭证缓存 + 直传逻辑)
  • 新增 API 文件 shopArchive.ts
  • 新增门店档案列表页
  • 新增门店档案详情/编辑页(含 Tab 分组、字段联动、附件分组上传)
  • 路由配置与菜单对接

Phase 4:测试与优化

  • 接口联调测试
  • OSS STS 凭证签发与直传测试
  • 附件上传/预览/下载/删除测试
  • 权限校验测试
  • 字段联动逻辑测试