通过上传Excel文件来实现产品对用户可见性的控制

最终需求描述

我有一个接口现在需要实现以下的需求,我有一个产品列表,需要通过excel文件实现指定客户可以查看还是所有客户可以查看,现在默认是所有客户都可以查看。允许用户上传文件,在用户上传完文件之后就对excel的文件的格式进行校验,文件第一列手机号码,第二列账户id。如果有问题需要报错提示文件格式错误供前端使用,如果没问题就把文件数据拿出来存到数据库里面去,供文件列表接口使用判断某个用户能看到哪些产品列表;还需要把文件存到oss里面去,方便客户下载文件重新上传。项目语言为go语言,数据库框架使用Gorm,我希望使用两个字段来解决,用户信息从excel解码之后存json存到数据库字段 customerManager JSON 里面,默认为NULL,还有一个字段表示该产品是全量客户可以查看指定客户可以查看,1表示全量,2表示指定,默认为1。

需求简单描述

我有一个接口现在需要实现以下的需求,我有一个产品列表,需要通过excel文件实现指定客户可以查看还是所有客户可以查看,现在默认是所有客户都可以查看。允许用户上传文件,在用户上传完文件之后就对excel的文件的格式进行校验,文件第一列手机号码,第二列账户id。如果有问题需要报错提示文件格式错误供前端使用,如果没问题就把文件数据拿出来存到数据库里面去,供文件列表接口使用判断某个用户能看到哪些产品列表;还需要把文件存到oss里面去,方便客户下载文件重新上传。项目语言为go语言

我理解您的需求。让我为您提供一个 Go 语言实现的大致框架和主要步骤。这个实现将包括文件上传、格式验证、数据存储和 OSS 上传功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
package main

import (
"database/sql"
"encoding/csv"
"fmt"
"io"
"mime/multipart"
"net/http"

"github.com/gin-gonic/gin"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/xuri/excelize/v2"
)

// 定义数据库和OSS客户端
var (
db *sql.DB
ossBucket *oss.Bucket
)

// 初始化数据库和OSS连接
func init() {
// 初始化数据库连接
var err error
db, err = sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/dbname")
if err != nil {
panic(err)
}

// 初始化OSS客户端
client, err := oss.New("endpoint", "accessKeyId", "accessKeySecret")
if err != nil {
panic(err)
}
ossBucket, err = client.Bucket("bucketName")
if err != nil {
panic(err)
}
}

func main() {
r := gin.Default()
r.POST("/upload", handleFileUpload)
r.Run(":8080")
}

func handleFileUpload(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件上传失败"})
return
}
defer file.Close()

// 验证文件格式
if !validateFileFormat(file) {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件格式错误"})
return
}

// 重置文件指针
file.Seek(0, 0)

// 保存数据到数据库
if err := saveDataToDatabase(file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存数据失败"})
return
}

// 上传文件到OSS
ossPath := fmt.Sprintf("uploads/%s", header.Filename)
if err := uploadFileToOSS(file, ossPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "上传到OSS失败"})
return
}

c.JSON(http.StatusOK, gin.H{"message": "文件上传成功"})
}

func validateFileFormat(file multipart.File) bool {
xlsx, err := excelize.OpenReader(file)
if err != nil {
return false
}

sheet := xlsx.GetActiveSheetIndex()
rows, err := xlsx.GetRows(xlsx.GetSheetName(sheet))
if err != nil || len(rows) < 1 {
return false
}

// 检查第一行是否包含两列
if len(rows[0]) != 2 {
return false
}

// 这里可以添加更多的验证逻辑,例如检查手机号格式等

return true
}

func saveDataToDatabase(file multipart.File) error {
xlsx, err := excelize.OpenReader(file)
if err != nil {
return err
}

sheet := xlsx.GetActiveSheetIndex()
rows, err := xlsx.GetRows(xlsx.GetSheetName(sheet))
if err != nil {
return err
}

// 开始事务
tx, err := db.Begin()
if err != nil {
return err
}

stmt, err := tx.Prepare("INSERT INTO product_visibility (phone, account_id) VALUES (?, ?)")
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()

for i, row := range rows {
if i == 0 { // 跳过标题行
continue
}
if len(row) != 2 {
tx.Rollback()
return fmt.Errorf("行 %d 格式错误", i+1)
}
_, err = stmt.Exec(row[0], row[1])
if err != nil {
tx.Rollback()
return err
}
}

return tx.Commit()
}

func uploadFileToOSS(file multipart.File, ossPath string) error {
return ossBucket.PutObject(ossPath, file)
}

这段代码提供了一个基本的框架来实现您的需求。以下是主要功能的解释:

  1. 文件上传:使用 Gin 框架处理文件上传。

  2. 文件格式验证:validateFileFormat 函数检查上传的 Excel 文件格式是否正确。

  3. 数据存储:saveDataToDatabase 函数将 Excel 文件中的数据保存到数据库中。

  4. OSS 上传:uploadFileToOSS 函数将文件上传到阿里云 OSS。

要完成这个实现,您还需要:

  1. 安装必要的依赖:

    1
    2
    3
    go get github.com/gin-gonic/gin
    go get github.com/aliyun/aliyun-oss-go-sdk/oss
    go get github.com/xuri/excelize/v2
  2. 配置数据库连接和 OSS 客户端信息。

  3. 创建相应的数据库表结构。

  4. 实现产品列表接口,根据用户的手机号或账户 ID 来过滤可见的产品。

  5. 添加错误处理和日志记录。

  6. 实现文件下载功能,允许用户下载之前上传的文件。

这个实现提供了一个良好的起点。您可能需要根据具体的业务需求进行调整和扩展。如果您需要更详细的解释或有任何问题,请随时告诉我。

这句代码意思是文件通过请求头传过来吗?

1
file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "文件上传失败"}) return } defer file.Close()

您的理解不完全准确。让我为您解释这段代码:

1
2
3
4
5
6
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件上传失败"})
return
}
defer file.Close()

这段代码是在处理通过 HTTP 请求上传的文件,但不是通过请求头传递的。这里的具体含义是:

  1. c.Request.FormFile("file") 是在尝试从 HTTP 请求的表单数据中获取一个名为 “file” 的文件。

  2. 文件通常是通过 HTTP POST 请求的请求体(body)中的 multipart/form-data 格式传输的,而不是通过请求头。

  3. FormFile 函数返回三个值:

    • file:一个 multipart.File 接口,可以用来读取文件内容。
    • header:一个 *multipart.FileHeader 结构,包含文件的元信息,如文件名等。
    • err:如果出现错误,这里会返回错误信息。
  4. 如果 err 不为 nil,表示获取文件失败,可能是因为没有文件被上传,或者文件名不是 “file”,或者其他原因。这时会返回一个错误响应。

  5. defer file.Close() 确保在函数结束时关闭文件,防止资源泄露。

总的来说,这段代码是在处理通过 HTTP 请求体上传的文件,而不是通过请求头。文件数据是包含在请求的主体部分,通常使用 multipart/form-data 编码格式。

如果您想了解更多关于如何在前端发送这样的请求,或者如何在 Postman 等工具中测试这个接口,我可以为您提供更多信息。您有什么具体想了解的吗?

我这里是接口,应该提供一个字段给前端做请求参数使用吧

您说得对。作为一个接口,我们确实应该提供明确的参数给前端使用。我为您修改一下这部分的实现,使其更符合接口的标准做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package main

import (
"database/sql"
"fmt"
"net/http"

"github.com/gin-gonic/gin"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/xuri/excelize/v2"
)

// 定义数据库和OSS客户端
var (
db *sql.DB
ossBucket *oss.Bucket
)

// 初始化数据库和OSS连接
func init() {
// ... (保持原有的初始化代码不变)
}

func main() {
r := gin.Default()
r.POST("/upload", handleFileUpload)
r.Run(":8080")
}

// UploadRequest 定义上传请求的结构
type UploadRequest struct {
File *multipart.FileHeader `form:"file" binding:"required"`
}

func handleFileUpload(c *gin.Context) {
var req UploadRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}

file, err := req.File.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法打开文件"})
return
}
defer file.Close()

// 验证文件格式
if !validateFileFormat(file) {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件格式错误"})
return
}

// 重置文件指针
file.Seek(0, 0)

// 保存数据到数据库
if err := saveDataToDatabase(file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存数据失败"})
return
}

// 上传文件到OSS
ossPath := fmt.Sprintf("uploads/%s", req.File.Filename)
if err := uploadFileToOSS(file, ossPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "上传到OSS失败"})
return
}

c.JSON(http.StatusOK, gin.H{"message": "文件上传成功"})
}

// validateFileFormat, saveDataToDatabase, uploadFileToOSS 函数保持不变
// ...

这个更新后的版本有以下几个关键改变:

  1. 定义了 UploadRequest 结构体,明确了接口期望的请求参数:

    1
    2
    3
    type UploadRequest struct {
    File *multipart.FileHeader `form:"file" binding:"required"`
    }

    这里 form:"file" 指定了前端应该使用的字段名,binding:"required" 表示这是一个必须的字段。

  2. 使用 c.ShouldBind(&req) 来绑定请求参数,这样可以自动进行参数验证。

  3. 使用 req.File.Open() 来打开上传的文件,而不是直接从请求中获取。

这样修改后,前端在调用这个接口时,需要以 multipart/form-data 格式发送请求,并且文件字段名必须为 “file”。例如,使用 JavaScript 的 Fetch API 调用这个接口可能是这样的:

1
2
3
4
5
6
7
8
9
10
const formData = new FormData();
formData.append('file', fileObject); // fileObject 是一个 File 对象

fetch('http://your-api-url/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

这种方式更加明确和标准化,让前端开发者能够更容易地理解如何使用这个接口。同时,它也提供了更好的错误处理和参数验证。

如果您需要添加其他参数,比如用户ID或者其他元数据,您可以在 UploadRequest 结构体中添加更多字段。例如:

1
2
3
4
type UploadRequest struct {
File *multipart.FileHeader `form:"file" binding:"required"`
UserID string `form:"userId" binding:"required"`
}

这样,前端就需要同时提供文件和用户ID。您对这个修改有什么看法或其他需求吗?

c.ShouldBind(&req);能检查出我的文件有没有格式问题吗?

您提出了一个很好的问题。c.ShouldBind(&req) 主要用于绑定和验证请求参数,但它不能检查文件的具体内容或格式。让我解释一下它的功能和局限性:

  1. c.ShouldBind(&req) 的功能:

    • 它会检查请求中是否存在名为 “file” 的文件字段。
    • 它会验证文件字段是否为空(因为我们使用了 binding:"required")。
    • 它会将文件信息绑定到 UploadRequest 结构体的 File 字段上。
  2. c.ShouldBind(&req) 的局限性:

    • 它不会检查文件的内容。
    • 它不会验证文件的格式(例如是否为 Excel 文件)。
    • 它不会检查文件的大小或其他具体属性。

要检查文件的具体格式和内容,我们需要在 c.ShouldBind(&req) 之后添加额外的验证步骤。我们可以修改代码来包含这些检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package main

import (
"database/sql"
"fmt"
"net/http"
"path/filepath"

"github.com/gin-gonic/gin"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/xuri/excelize/v2"
)

// ... (保留之前的 import 和全局变量定义)

type UploadRequest struct {
File *multipart.FileHeader `form:"file" binding:"required"`
}

func handleFileUpload(c *gin.Context) {
var req UploadRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}

// 检查文件扩展名
if !isValidFileExtension(req.File.Filename) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件类型,请上传 Excel 文件"})
return
}

file, err := req.File.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法打开文件"})
return
}
defer file.Close()

// 验证文件格式和内容
if err := validateFileContent(file); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// 重置文件指针
file.Seek(0, 0)

// ... (保留后续的数据库保存和 OSS 上传逻辑)
}

func isValidFileExtension(filename string) bool {
ext := filepath.Ext(filename)
return ext == ".xlsx" || ext == ".xls"
}

func validateFileContent(file multipart.File) error {
xlsx, err := excelize.OpenReader(file)
if err != nil {
return fmt.Errorf("无法读取 Excel 文件:%v", err)
}

sheet := xlsx.GetActiveSheetIndex()
rows, err := xlsx.GetRows(xlsx.GetSheetName(sheet))
if err != nil {
return fmt.Errorf("无法获取工作表数据:%v", err)
}

if len(rows) < 2 {
return fmt.Errorf("文件内容为空或只有标题行")
}

// 检查标题行
if len(rows[0]) != 2 || rows[0][0] != "手机号码" || rows[0][1] != "账户ID" {
return fmt.Errorf("文件格式错误:第一行应为 '手机号码' 和 '账户ID'")
}

// 检查数据行
for i, row := range rows[1:] {
if len(row) != 2 {
return fmt.Errorf("第 %d 行数据格式错误", i+2)
}
// 这里可以添加更多的验证,比如检查手机号格式、账户ID格式等
}

return nil
}

// ... (保留其他函数不变)

这个更新版本增加了以下几个关键点:

  1. isValidFileExtension 函数检查文件扩展名是否为 .xlsx 或 .xls。

  2. validateFileContent 函数执行更详细的文件内容验证:

    • 检查是否能成功打开为 Excel 文件。
    • 验证文件是否至少包含标题行和一行数据。
    • 检查标题行是否符合预期格式(”手机号码” 和 “账户ID”)。
    • 检查每行数据是否包含两列。
  3. handleFileUpload 函数中,我们现在进行了两步验证:

    • 首先检查文件扩展名。
    • 然后验证文件内容。

这样,我们不仅验证了请求参数的存在性(通过 c.ShouldBind),还验证了文件的格式和内容。这种方法可以有效地捕获各种可能的文件格式问题,并给出具体的错误信息。

您可以根据具体需求进一步调整验证逻辑,比如添加对手机号码格式的检查,或者对账户ID的特定要求等。这样可以确保上传的文件完全符合您的业务需求。

这种实现方式能够更好地保护您的系统,避免处理格式不正确的文件,同时也为用户提供了明确的错误反馈。您对这个实现有什么看法或其他需求吗?

假如这个文件是第一列手机号,第二列账户id,我能把这个excel的文件里面的字段解析出来当作json存到产品表的一个字段里面吗?这样我每次查询产品详情的时候或者查询产品列表的时候就可以直接通过这个字段查出来解析之后的账户id来判断这个产品是应该展示给所有用户看的还是应该只展示给某些用户看的

当然可以。这是一个很好的想法,可以提高查询效率。我们可以将Excel文件中的数据解析为JSON格式,然后存储在产品表的一个JSON类型字段中。这样做的好处是可以快速查询,而不需要每次都关联其他表。

让我们修改代码来实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"path/filepath"

"github.com/gin-gonic/gin"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/xuri/excelize/v2"
)

// ... (保留之前的 import 和全局变量定义)

type UploadRequest struct {
File *multipart.FileHeader `form:"file" binding:"required"`
ProductID int64 `form:"productId" binding:"required"`
}

type VisibilityData struct {
AllowedUsers []UserInfo `json:"allowedUsers"`
}

type UserInfo struct {
Phone string `json:"phone"`
AccountID string `json:"accountId"`
}

func handleFileUpload(c *gin.Context) {
var req UploadRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}

// 检查文件扩展名
if !isValidFileExtension(req.File.Filename) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件类型,请上传 Excel 文件"})
return
}

file, err := req.File.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法打开文件"})
return
}
defer file.Close()

// 解析文件内容
visibilityData, err := parseExcelToJSON(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// 将 JSON 数据保存到数据库
if err := saveVisibilityDataToDB(req.ProductID, visibilityData); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存数据失败"})
return
}

// ... (保留 OSS 上传逻辑)

c.JSON(http.StatusOK, gin.H{"message": "文件上传成功,产品可见性数据已更新"})
}

func parseExcelToJSON(file multipart.File) (VisibilityData, error) {
xlsx, err := excelize.OpenReader(file)
if err != nil {
return VisibilityData{}, fmt.Errorf("无法读取 Excel 文件:%v", err)
}

sheet := xlsx.GetActiveSheetIndex()
rows, err := xlsx.GetRows(xlsx.GetSheetName(sheet))
if err != nil {
return VisibilityData{}, fmt.Errorf("无法获取工作表数据:%v", err)
}

if len(rows) < 2 {
return VisibilityData{}, fmt.Errorf("文件内容为空或只有标题行")
}

var visibilityData VisibilityData
for i, row := range rows[1:] { // 跳过标题行
if len(row) != 2 {
return VisibilityData{}, fmt.Errorf("第 %d 行数据格式错误", i+2)
}
visibilityData.AllowedUsers = append(visibilityData.AllowedUsers, UserInfo{
Phone: row[0],
AccountID: row[1],
})
}

return visibilityData, nil
}

func saveVisibilityDataToDB(productID int64, data VisibilityData) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("JSON 序列化失败:%v", err)
}

_, err = db.Exec("UPDATE products SET visibility_data = ? WHERE id = ?", jsonData, productID)
if err != nil {
return fmt.Errorf("更新数据库失败:%v", err)
}

return nil
}

// ... (保留其他函数不变)

这个更新版本添加了以下关键功能:

  1. 新增了 VisibilityDataUserInfo 结构体,用于表示可见性数据的 JSON 结构。

  2. 修改了 UploadRequest 结构体,添加了 ProductID 字段,用于指定要更新的产品。

  3. 新增了 parseExcelToJSON 函数,用于将 Excel 文件内容解析为 VisibilityData 结构。

  4. 新增了 saveVisibilityDataToDB 函数,用于将解析后的 JSON 数据保存到数据库的产品表中。

  5. handleFileUpload 函数中,我们现在解析 Excel 文件并将数据保存到数据库,而不是之前的逐行保存方式。

使用这种方法,您可以在产品表中添加一个名为 visibility_data 的 JSON 类型字段(在 MySQL 中可以使用 JSON 类型,在其他数据库中可能需要使用 TEXT 或 JSONB 类型)。

现在,当您查询产品详情或产品列表时,可以直接从这个 JSON 字段中获取可见性信息。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func getProductVisibility(productID int64, userAccountID string) (bool, error) {
var visibilityDataJSON string
err := db.QueryRow("SELECT visibility_data FROM products WHERE id = ?", productID).Scan(&visibilityDataJSON)
if err != nil {
return false, err
}

var visibilityData VisibilityData
if err := json.Unmarshal([]byte(visibilityDataJSON), &visibilityData); err != nil {
return false, err
}

// 如果 AllowedUsers 为空,则所有用户可见
if len(visibilityData.AllowedUsers) == 0 {
return true, nil
}

// 检查用户是否在允许列表中
for _, user := range visibilityData.AllowedUsers {
if user.AccountID == userAccountID {
return true, nil
}
}

return false, nil
}

这种方法的优点是:

  1. 查询效率高,不需要关联其他表。
  2. 可以轻松地在应用层面处理可见性逻辑。
  3. 灵活性强,可以轻松添加更多的可见性规则。

缺点是:

  1. 数据冗余,同样的用户信息可能在多个产品中重复。
  2. 更新单个用户的可见性需要更新所有相关产品的 JSON 数据。

根据您的具体需求和数据规模,这种方法可能是一个很好的平衡。如果您预计频繁地更新单个用户的可见性,或者数据量非常大,可能需要考虑其他的数据模型。您对这个实现有什么看法或其他需求吗?

面对这个需求还有没有其他更好的设计方案

您提出了一个很好的问题。确实,对于这种需求,可能还有其他更优的设计方案。让我们探讨一下几种可能的替代方案,并分析它们的优缺点:

  1. 关联表方案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 产品表
CREATE TABLE products (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
-- 其他产品字段
);

-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20),
account_id VARCHAR(50) UNIQUE
-- 其他用户字段
);

-- 产品可见性表
CREATE TABLE product_visibility (
id INT PRIMARY KEY AUTO_INCREMENT,
product_id INT,
user_id INT,
FOREIGN KEY (product_id) REFERENCES products(id),
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE KEY (product_id, user_id)
);

-- 查询某个用户可见的所有产品
SELECT p.*
FROM products p
LEFT JOIN product_visibility pv ON p.id = pv.product_id
LEFT JOIN users u ON pv.user_id = u.id
WHERE u.account_id = ? OR pv.id IS NULL;

-- 查询某个产品对特定用户是否可见
SELECT COUNT(*) > 0 AS is_visible
FROM products p
LEFT JOIN product_visibility pv ON p.id = pv.product_id
LEFT JOIN users u ON pv.user_id = u.id
WHERE p.id = ? AND (u.account_id = ? OR pv.id IS NULL);

优点:

  • 数据一致性强,避免了数据冗余
  • 易于管理和更新单个用户或产品的可见性
  • 支持复杂的查询和分析

缺点:

  • 查询可能需要多表连接,在数据量大时可能影响性能
  • 实现稍微复杂一些
  1. 位图索引方案

对于大规模数据,我们可以考虑使用位图索引来优化性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import (
"github.com/RoaringBitmap/roaring"
"encoding/binary"
)

type ProductVisibility struct {
ProductID int64
UserBitmap *roaring.Bitmap
}

func SaveVisibility(productID int64, userIDs []int32) error {
bitmap := roaring.New()
bitmap.AddMany(userIDs)

data, err := bitmap.MarshalBinary()
if err != nil {
return err
}

_, err = db.Exec("INSERT INTO product_visibility (product_id, user_bitmap) VALUES (?, ?) ON DUPLICATE KEY UPDATE user_bitmap = ?", productID, data, data)
return err
}

func IsProductVisible(productID int64, userID int32) (bool, error) {
var bitmapData []byte
err := db.QueryRow("SELECT user_bitmap FROM product_visibility WHERE product_id = ?", productID).Scan(&bitmapData)
if err != nil {
if err == sql.ErrNoRows {
return true, nil // 如果没有限制,则对所有用户可见
}
return false, err
}

bitmap := roaring.New()
err = bitmap.UnmarshalBinary(bitmapData)
if err != nil {
return false, err
}

return bitmap.Contains(userID), nil
}

优点:

  • 非常高效的存储和查询,特别是对于大规模数据
  • 支持快速的集合操作(如交集、并集)

缺点:

  • 实现相对复杂
  • 可能需要额外的库支持
  • 不易于直接在数据库层面进行复杂查询
  1. 缓存层方案

我们还可以考虑添加一个缓存层来优化频繁的查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import (
"github.com/go-redis/redis/v8"
"encoding/json"
"context"
)

var redisClient *redis.Client

func init() {
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
}

func IsProductVisible(productID int64, userAccountID string) (bool, error) {
ctx := context.Background()
key := fmt.Sprintf("product_visibility:%d", productID)

// 尝试从缓存获取
visibilityData, err := redisClient.Get(ctx, key).Result()
if err == nil {
var allowedUsers []string
err = json.Unmarshal([]byte(visibilityData), &allowedUsers)
if err != nil {
return false, err
}

if len(allowedUsers) == 0 {
return true, nil // 所有用户可见
}

for _, allowedUser := range allowedUsers {
if allowedUser == userAccountID {
return true, nil
}
}
return false, nil
}

// 缓存未命中,从数据库查询
var allowedUsers []string
err = db.QueryRow("SELECT allowed_users FROM product_visibility WHERE product_id = ?", productID).Scan(&allowedUsers)
if err != nil {
if err == sql.ErrNoRows {
return true, nil // 如果没有限制,则对所有用户可见
}
return false, err
}

// 更新缓存
allowedUsersJSON, _ := json.Marshal(allowedUsers)
redisClient.Set(ctx, key, allowedUsersJSON, 24*time.Hour)

if len(allowedUsers) == 0 {
return true, nil
}

for _, allowedUser := range allowedUsers {
if allowedUser == userAccountID {
return true, nil
}
}
return false, nil
}

优点:

  • 大幅提高查询性能,特别是对于频繁访问的数据
  • 减轻数据库负载
  • 可以与其他方案结合使用

缺点:

  • 需要额外的缓存基础设施
  • 需要处理缓存一致性问题
  • 增加了系统复杂性

建议:

  1. 对于中小规模数据,关联表方案可能是最佳选择,因为它简单直接,易于维护和扩展。

  2. 如果您的系统需要处理大规模数据和高并发查询,位图索引方案可能更合适。

  3. 无论选择哪种方案,添加缓存层都可以显著提升性能,特别是对于读多写少的场景。

  4. 您还可以考虑混合方案,例如使用关联表存储数据,但在应用层使用位图进行内存中的快速查询。

选择最佳方案需要考虑您的具体需求,包括数据规模、查询频率、写入频率、扩展性需求等。您可能需要进行一些性能测试来确定哪种方案最适合您的使用场景。

您对这些方案有什么看法?是否有任何特定的关注点或约束条件需要考虑?

位图索引方案如何实现我们这个需求呢

我有一个接口现在需要实现以下的需求,我有一个产品列表,需要通过excel文件实现指定客户可以查看还是所有客户可以查看,现在默认是所有客户都可以查看。允许用户上传文件,在用户上传完文件之后就对excel的文件的格式进行校验,文件第一列手机号码,第二列账户id。如果有问题需要报错提示文件格式错误供前端使用,如果没问题就把文件数据拿出来存到数据库里面去,供文件列表接口使用判断某个用户能看到哪些产品列表;还需要把文件存到oss里面去,方便客户下载文件重新上传。项目语言为go语言

我来帮你设计这个接口的实现方案。以下是详细的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
package handler

import (
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"database/sql"
"fmt"
"time"
)

// ProductVisibility 产品可见性结构
type ProductVisibility struct {
Phone string `json:"phone"`
AccountID string `json:"account_id"`
}

// UploadResponse 上传响应
type UploadResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}

func HandleFileUpload(c *gin.Context) {
// 1. 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件上传失败",
})
return
}
defer file.Close()

// 2. 检查文件类型
if !isExcelFile(header.Filename) {
c.JSON(400, UploadResponse{
Code: 400,
Message: "请上传Excel文件",
})
return
}

// 3. 读取并验证Excel内容
xlsx, err := excelize.OpenReader(file)
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: "无法读取Excel文件",
})
return
}

// 4. 验证并解析数据
rows, err := validateAndParseExcel(xlsx)
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: err.Error(),
})
return
}

// 5. 保存到OSS
ossPath, err := saveToOSS(file, header.Filename)
if err != nil {
c.JSON(500, UploadResponse{
Code: 500,
Message: "文件存储失败",
})
return
}

// 6. 保存数据到数据库
err = saveToDatabase(rows, ossPath)
if err != nil {
c.JSON(500, UploadResponse{
Code: 500,
Message: "数据保存失败",
})
return
}

c.JSON(200, UploadResponse{
Code: 200,
Message: "上传成功",
})
}

// 验证是否为Excel文件
func isExcelFile(filename string) bool {
return strings.HasSuffix(filename, ".xlsx") || strings.HasSuffix(filename, ".xls")
}

// 验证并解析Excel内容
func validateAndParseExcel(xlsx *excelize.File) ([]ProductVisibility, error) {
rows, err := xlsx.GetRows("Sheet1")
if err != nil {
return nil, fmt.Errorf("无法读取工作表")
}

if len(rows) < 2 { // 至少要有标题行和一行数据
return nil, fmt.Errorf("文件内容为空")
}

var results []ProductVisibility
for i, row := range rows {
if i == 0 { // 跳过标题行
continue
}

if len(row) < 2 {
return nil, fmt.Errorf("第%d行数据格式错误", i+1)
}

// 验证手机号格式
if !validatePhone(row[0]) {
return nil, fmt.Errorf("第%d行手机号格式错误", i+1)
}

// 验证账户ID格式
if !validateAccountID(row[1]) {
return nil, fmt.Errorf("第%d行账户ID格式错误", i+1)
}

results = append(results, ProductVisibility{
Phone: row[0],
AccountID: row[1],
})
}

return results, nil
}

// 保存到OSS
func saveToOSS(file io.Reader, filename string) (string, error) {
// OSS配置
client, err := oss.New("endpoint", "accessKeyId", "accessKeySecret")
if err != nil {
return "", err
}

bucket, err := client.Bucket("bucketName")
if err != nil {
return "", err
}

ossPath := fmt.Sprintf("product_visibility/%s_%s", time.Now().Format("20060102150405"), filename)
err = bucket.PutObject(ossPath, file)
if err != nil {
return "", err
}

return ossPath, nil
}

// 保存到数据库
func saveToDatabase(rows []ProductVisibility, ossPath string) error {
// 数据库连接配置
db, err := sql.Open("mysql", "user:password@tcp(host:port)/dbname")
if err != nil {
return err
}
defer db.Close()

// 开启事务
tx, err := db.Begin()
if err != nil {
return err
}

// 准备SQL语句
stmt, err := tx.Prepare(`
INSERT INTO product_visibility (phone, account_id, file_path, created_at)
VALUES (?, ?, ?, NOW())
`)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()

// 插入数据
for _, row := range rows {
_, err := stmt.Exec(row.Phone, row.AccountID, ossPath)
if err != nil {
tx.Rollback()
return err
}
}

return tx.Commit()
}

// 验证手机号
func validatePhone(phone string) bool {
matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone)
return matched
}

// 验证账户ID
func validateAccountID(accountID string) bool {
// 根据实际需求实现账户ID的验证规则
return len(accountID) > 0
}

以上代码实现了以下功能:

  1. 文件上传处理:

    • 验证上传的文件是否为Excel格式
    • 读取Excel文件内容
    • 验证文件格式和数据有效性
  2. 数据验证:

    • 验证Excel文件必须包含两列:手机号和账户ID
    • 验证手机号格式是否正确
    • 验证账户ID是否有效
  3. OSS存储:

    • 将文件保存到阿里云OSS
    • 生成唯一的文件路径
  4. 数据库存储:

    • 使用事务保证数据一致性
    • 保存用户可见性配置
    • 保存文件路径信息

使用该接口的步骤:

  1. 前端上传Excel文件到该接口
  2. 接口返回处理结果:
    • 成功:返回成功信息
    • 失败:返回具体的错误信息

数据库表结构建议:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE product_visibility (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
account_id VARCHAR(50) NOT NULL,
file_path VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_phone (phone),
INDEX idx_account_id (account_id)
);

前端可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 使用FormData上传文件
const formData = new FormData();
formData.append('file', file);

fetch('/api/upload-visibility', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.code === 200) {
// 上传成功
alert('上传成功');
} else {
// 显示错误信息
alert(data.message);
}
})
.catch(error => {
console.error('上传失败:', error);
});

需要注意的点:

  1. 需要配置正确的OSS和数据库连接信息
  2. 根据实际需求调整文件格式验证规则
  3. 可以添加文件大小限制
  4. 考虑添加并发控制
  5. 可以添加日志记录
  6. 建议添加超时处理
  7. 可以考虑添加异步处理大文件的机制

这个实现提供了基本的功能,你可以根据具体需求进行调整和扩展。

这个是什么方案?除了这个方案还有没有其他方案

我刚才介绍的是直接处理方案,就是同步处理Excel文件。下面我介绍几种其他可行的方案:

  1. 异步处理方案(推荐):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 1. 异步处理方案的代码实现
package handler

import (
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)

// FileTask 文件处理任务
type FileTask struct {
ID string `json:"id"`
FilePath string `json:"file_path"`
Status string `json:"status"`
}

func HandleAsyncFileUpload(c *gin.Context) {
// 1. 保存文件到临时目录
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "文件上传失败"})
return
}

// 2. 生成任务ID
taskID := generateTaskID()

// 3. 保存到OSS
ossPath, err := saveToOSS(file, header.Filename)
if err != nil {
c.JSON(500, gin.H{"error": "文件存储失败"})
return
}

// 4. 将任务发送到消息队列
task := FileTask{
ID: taskID,
FilePath: ossPath,
Status: "pending",
}

err = publishTask(task)
if err != nil {
c.JSON(500, gin.H{"error": "任务创建失败"})
return
}

c.JSON(200, gin.H{
"task_id": taskID,
"message": "文件已上传,正在处理中",
})
}

// 任务处理器
func processFileTask(task FileTask) {
// 1. 从OSS下载文件
// 2. 解析Excel
// 3. 验证数据
// 4. 保存到数据库
// 5. 更新任务状态
}
  1. 分片上传方案(适合大文件):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 2. 分片上传方案的代码实现
type ChunkInfo struct {
ChunkNumber int `json:"chunk_number"`
TotalChunks int `json:"total_chunks"`
FileHash string `json:"file_hash"`
}

func HandleChunkUpload(c *gin.Context) {
chunkInfo := ChunkInfo{}
if err := c.ShouldBind(&chunkInfo); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}

// 1. 保存分片到临时目录
chunk, err := c.FormFile("chunk")
if err != nil {
c.JSON(400, gin.H{"error": "分片上传失败"})
return
}

// 2. 保存分片
err = saveChunk(chunk, chunkInfo)
if err != nil {
c.JSON(500, gin.H{"error": "分片保存失败"})
return
}

// 3. 如果是最后一个分片,合并文件
if chunkInfo.ChunkNumber == chunkInfo.TotalChunks {
err = mergeChunks(chunkInfo)
if err != nil {
c.JSON(500, gin.H{"error": "文件合并失败"})
return
}
}

c.JSON(200, gin.H{"message": "分片上传成功"})
}
  1. 数据库直接导入方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 3. 数据库直接导入方案的代码实现
func HandleDBImport(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "文件上传失败"})
return
}

// 1. 保存到临时文件
tempFile := fmt.Sprintf("/tmp/%s", header.Filename)
err = saveToTemp(file, tempFile)
if err != nil {
c.JSON(500, gin.H{"error": "临时文件保存失败"})
return
}

// 2. 使用数据库的LOAD DATA命令导入
err = importToDB(tempFile)
if err != nil {
c.JSON(500, gin.H{"error": "数据导入失败"})
return
}

c.JSON(200, gin.H{"message": "数据导入成功"})
}
  1. 使用ETL工具方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 4. ETL工具方案的代码实现
func HandleETLImport(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "文件上传失败"})
return
}

// 1. 保存文件到ETL工具监控的目录
etlPath := fmt.Sprintf("/etl/input/%s", header.Filename)
err = saveToETLPath(file, etlPath)
if err != nil {
c.JSON(500, gin.H{"error": "文件保存失败"})
return
}

c.JSON(200, gin.H{"message": "文件已提交到ETL处理"})
}

各方案比较:

  1. 异步处理方案:

    • 优点:
      • 用户体验好,快速响应
      • 可以处理大量数据
      • 失败重试机制
    • 缺点:
      • 需要额外的消息队列系统
      • 实现复杂度较高
  2. 分片上传方案:

    • 优点:
      • 支持大文件上传
      • 断点续传
      • 上传稳定性高
    • 缺点:
      • 实现复杂
      • 需要前端配合
      • 需要管理临时文件
  3. 数据库直接导入方案:

    • 优点:
      • 性能高
      • 实现简单
    • 缺点:
      • 灵活性差
      • 数据验证不便
      • 依赖数据库功能
  4. ETL工具方案:

    • 优点:
      • 功能强大
      • 可视化配置
      • 支持复杂转换
    • 缺点:
      • 需要额外工具
      • 学习成本高
      • 部署复杂

推荐方案选择:

  1. 如果数据量小(<1000行):使用同步处理方案
  2. 如果数据量中等(1000-10000行):使用异步处理方案
  3. 如果数据量大(>10000行):使用ETL工具方案
  4. 如果文件很大(>100MB):使用分片上传方案

具体实现建议:

  1. 异步处理方案实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    // 使用Redis作为消息队列
    type FileProcessor struct {
    redis *redis.Client
    queue string
    }

    func NewFileProcessor() *FileProcessor {
    return &FileProcessor{
    redis: redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
    }),
    queue: "file_tasks",
    }
    }

    func (p *FileProcessor) Start() {
    for {
    // 监听任务
    result, err := p.redis.BLPop(context.Background(), 0, p.queue).Result()
    if err != nil {
    continue
    }

    // 处理任务
    var task FileTask
    json.Unmarshal([]byte(result[1]), &task)
    go p.processTask(task)
    }
    }
  2. 分片上传实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 前端实现
    function uploadChunk(file, chunkSize) {
    const chunks = Math.ceil(file.size / chunkSize);
    for (let i = 0; i < chunks; i++) {
    const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunk_number', i + 1);
    formData.append('total_chunks', chunks);
    // 上传分片
    await fetch('/api/upload-chunk', {
    method: 'POST',
    body: formData
    });
    }
    }

这些方案都有其适用场景,建议根据具体需求选择合适的方案:

  • 如果要求实时性:选择同步处理方案
  • 如果要求用户体验:选择异步处理方案
  • 如果数据量大:选择ETL工具方案
  • 如果文件大:选择分片上传方案

我有一个接口现在需要实现以下的需求,我有一个产品列表,需要通过excel文件实现指定客户可以查看还是所有客户可以查看,现在默认是所有客户都可以查看。允许用户上传文件,在用户上传完文件之后就对excel的文件的格式进行校验,文件第一列手机号码,第二列账户id。如果有问题需要报错提示文件格式错误供前端使用,如果没问题就把文件数据拿出来存到数据库里面去,供文件列表接口使用判断某个用户能看到哪些产品列表;还需要把文件存到oss里面去,方便客户下载文件重新上传。项目语言为go语言,数据库框架使用Gorm,我希望使用两个字段来解决,用户信息从excel解码之后存json存到数据库字段 customerManager JSON 里面,默认为NULL,还有一个字段表示该产品是全量客户可以查看指定客户可以查看,1表示全量,2表示指定,默认为1。

我来帮你设计这个功能的实现方案。以下是详细的代码实现:

  1. 首先定义相关的结构体:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 产品模型
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
CustomerManager *string `gorm:"type:json"` // 存储客户信息的JSON
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
// 其他产品字段...
}

// Excel数据结构
type CustomerInfo struct {
Phone string `json:"phone"`
AccountID string `json:"account_id"`
}
  1. 实现文件上传和验证的处理函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import (
"github.com/xuri/excelize/v2"
"encoding/json"
"errors"
)

func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
// 1. 读取Excel文件
src, err := file.Open()
if err != nil {
return errors.New("无法打开文件")
}
defer src.Close()

// 2. 保存文件到OSS
ossPath, err := uploadToOSS(src)
if err != nil {
return errors.New("文件上传OSS失败")
}

// 3. 解析Excel文件
f, err := excelize.OpenReader(src)
if err != nil {
return errors.New("无法解析Excel文件")
}
defer f.Close()

// 4. 读取第一个sheet的内容
rows, err := f.GetRows("Sheet1")
if err != nil {
return errors.New("读取Excel内容失败")
}

// 5. 验证并解析数据
var customers []CustomerInfo
for i, row := range rows {
if i == 0 { // 跳过标题行
continue
}
if len(row) < 2 {
return errors.New("文件格式错误:每行必须包含手机号和账户ID")
}

// 验证手机号格式
if !validatePhoneNumber(row[0]) {
return errors.New("文件格式错误:无效的手机号码")
}

customers = append(customers, CustomerInfo{
Phone: row[0],
AccountID: row[1],
})
}

// 6. 将数据转换为JSON
customerJSON, err := json.Marshal(customers)
if err != nil {
return errors.New("数据处理失败")
}

// 7. 更新数据库
db := GetDB() // 获取你的GORM DB实例
jsonStr := string(customerJSON)
err = db.Model(&Product{}).Where("id = ?", productID).Updates(map[string]interface{}{
"customer_manager": &jsonStr,
"view_type": 2,
}).Error
if err != nil {
return errors.New("数据库更新失败")
}

return nil
}
  1. 验证手机号的辅助函数:
1
2
3
4
5
func validatePhoneNumber(phone string) bool {
// 这里使用正则表达式验证手机号格式
matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone)
return matched
}
  1. OSS上传函数示例:
1
2
3
4
5
6
func uploadToOSS(file io.Reader) (string, error) {
// 实现文件上传到OSS的逻辑
// 返回文件在OSS上的路径
// 这里需要根据你使用的具体OSS服务来实现
return "", nil
}
  1. 查询产品列表的接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func GetProductList(userPhone, userAccountID string) ([]Product, error) {
db := GetDB()
var products []Product

query := db.Model(&Product{})

// 查询条件:全量可见或者指定用户可见
query = query.Where("view_type = 1 OR (view_type = 2 AND customer_manager IS NOT NULL AND (customer_manager->>'$[*].phone' LIKE ? OR customer_manager->>'$[*].account_id' LIKE ?))",
"%"+userPhone+"%",
"%"+userAccountID+"%")

err := query.Find(&products).Error
if err != nil {
return nil, err
}

return products, nil
}

使用说明:

  1. 前端上传Excel文件时,调用HandleCustomerUpload函数处理文件。
  2. 文件格式验证包括:
    • 检查是否有足够的列
    • 验证手机号格式
    • 验证Excel基本格式
  3. 数据存储:
    • customerManager字段以JSON格式存储客户信息
    • viewType字段标识查看权限类型
  4. 查询产品列表时,根据用户信息筛选可见产品

注意事项:

  1. 需要安装必要的依赖:

    1
    go get github.com/xuri/excelize/v2
  2. 需要配置OSS相关的认证信息和配置。

  3. 数据库需要支持JSON类型查询。

  4. 建议添加适当的错误处理和日志记录。

  5. 可以根据实际需求添加更多的验证规则和错误提示。

这个实现方案提供了基本的功能框架,你可以根据具体需求进行修改和扩展。如果有任何特殊需求或需要更详细的说明,请告诉我。

当一个客户想要查看列表的时候,默认可以查看全部的viewType=1的产品,然后去查找viewType = 2 的产品里面有没有这个用户的账号id就行了

明白了,我来修改查询产品列表的实现,让它更符合你的需求。以下是优化后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 产品模型
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
CustomerManager *string `gorm:"type:json"` // 存储客户信息的JSON,格式为 [{account_id: "xxx"}]
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
// 其他产品字段...
}

// CustomerInfo 结构体
type CustomerInfo struct {
AccountID string `json:"account_id"`
}

// GetProductList 获取用户可见的产品列表
func GetProductList(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

// 1. 首先查询所有 ViewType = 1 的产品(全量可见)
// 2. 然后使用 OR 条件查询 ViewType = 2 且 CustomerManager 中包含用户账号的产品
err := db.Where("view_type = 1 OR (view_type = 2 AND JSON_CONTAINS(customer_manager, ?, '$.account_id'))",
accountID).
Find(&products).Error

if err != nil {
return nil, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, nil
}

// 如果你使用的是 PostgreSQL,查询语句需要稍作调整:
func GetProductListPostgres(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

err := db.Where("view_type = 1 OR (view_type = 2 AND customer_manager::jsonb @> ?::jsonb)",
fmt.Sprintf(`[{"account_id": "%s"}]`, accountID)).
Find(&products).Error

if err != nil {
return nil, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, nil
}

对应的文件上传处理函数也需要相应修改,只存储账号ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
// 1. 读取Excel文件
src, err := file.Open()
if err != nil {
return errors.New("无法打开文件")
}
defer src.Close()

// 2. 保存文件到OSS
ossPath, err := uploadToOSS(src)
if err != nil {
return errors.New("文件上传OSS失败")
}

// 3. 解析Excel文件
f, err := excelize.OpenReader(src)
if err != nil {
return errors.New("无法解析Excel文件")
}
defer f.Close()

// 4. 读取第一个sheet的内容
rows, err := f.GetRows("Sheet1")
if err != nil {
return errors.New("读取Excel内容失败")
}

// 5. 验证并解析数据
var customers []CustomerInfo
for i, row := range rows {
if i == 0 { // 跳过标题行
continue
}
if len(row) < 2 {
return errors.New("文件格式错误:每行必须包含账户ID")
}

// 只存储账户ID
customers = append(customers, CustomerInfo{
AccountID: row[1], // 使用第二列的账户ID
})
}

// 6. 将数据转换为JSON
customerJSON, err := json.Marshal(customers)
if err != nil {
return errors.New("数据处理失败")
}

// 7. 更新数据库
db := GetDB()
jsonStr := string(customerJSON)
err = db.Model(&Product{}).Where("id = ?", productID).Updates(map[string]interface{}{
"customer_manager": &jsonStr,
"view_type": 2,
}).Error
if err != nil {
return errors.New("数据库更新失败")
}

return nil
}

主要改动说明:

  1. 简化了数据结构,只存储账号ID,不再存储手机号
  2. 查询逻辑更清晰:
    • ViewType = 1 的产品所有人可见
    • ViewType = 2 的产品只对指定账号可见
  3. 使用数据库的 JSON 查询功能来检查用户是否在允许列表中

使用示例:

1
2
3
4
5
6
7
8
9
10
11
// 调用示例
products, err := GetProductList("user123")
if err != nil {
// 处理错误
return
}

// 使用产品列表
for _, product := range products {
// 处理每个产品...
}

注意事项:

  1. 确保数据库支持 JSON 查询操作
  2. MySQL 和 PostgreSQL 的 JSON 查询语法有所不同,需要根据实际使用的数据库选择对应的查询方式
  3. 建议给 customer_manager 字段建立索引以提高查询性能
  4. 可以根据需求添加分页功能
  5. 建议添加适当的错误处理和日志记录

这个实现更加简洁和高效,直接根据 ViewType 和用户账号ID进行查询,避免了复杂的字符串匹配操作。如果你有任何其他需求或需要更详细的说明,请告诉我。

JSON_CONTAINS的功能是什么?查询效率怎么样

JSON_CONTAINS 是 MySQL 提供的一个 JSON 操作函数,用于检查一个 JSON 文档是否包含另一个 JSON 文档或值。

基本语法:

1
JSON_CONTAINS(target, candidate[, path])
  • target: 要搜索的 JSON 文档
  • candidate: 要查找的值
  • path: 可选参数,指定在 JSON 文档中的搜索路径

但对于你的场景,使用 JSON_CONTAINS 可能不是最优的选择,因为:

  1. 查询效率问题:

    • JSON_CONTAINS 需要扫描整个 JSON 数组
    • 即使建立了索引,JSON 查询的效率也不如普通字段
    • 当数据量大时,性能会下降明显
  2. 不同数据库的兼容性:

    • MySQL 和 PostgreSQL 的 JSON 查询语法不同
    • 迁移数据库时需要修改代码

建议的优化方案:

  1. 使用关联表方案(推荐):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 产品表
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
// 其他字段...
}

// 产品可见性关联表
type ProductVisibility struct {
ID uint `gorm:"primaryKey"`
ProductID uint `gorm:"index"`
AccountID string `gorm:"index"`
}

// 查询产品列表
func GetProductList(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

err := db.Distinct("products.*").
Joins("LEFT JOIN product_visibilities ON products.id = product_visibilities.product_id").
Where("products.view_type = 1 OR (products.view_type = 2 AND product_visibilities.account_id = ?)",
accountID).
Find(&products).Error

if err != nil {
return nil, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, nil
}

// 处理Excel上传
func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
// ... 文件读取和验证逻辑 ...

// 开启事务
tx := db.Begin()

// 1. 更新产品查看类型
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("view_type", 2).Error; err != nil {
tx.Rollback()
return err
}

// 2. 删除旧的可见性记录
if err := tx.Where("product_id = ?", productID).
Delete(&ProductVisibility{}).Error; err != nil {
tx.Rollback()
return err
}

// 3. 批量插入新的可见性记录
var visibilities []ProductVisibility
for _, row := range rows {
if len(row) < 2 {
continue
}
visibilities = append(visibilities, ProductVisibility{
ProductID: productID,
AccountID: row[1],
})
}

if len(visibilities) > 0 {
if err := tx.CreateInBatches(visibilities, 100).Error; err != nil {
tx.Rollback()
return err
}
}

return tx.Commit().Error
}

使用关联表的优势:

  1. 更好的查询性能:

    • 可以充分利用数据库索引
    • 支持高效的联表查询
    • 适合大数据量场景
  2. 更好的维护性:

    • 数据结构清晰
    • 方便添加和删除单个账号的权限
    • 便于统计和分析
  3. 更好的扩展性:

    • 可以轻松添加新的权限相关字段
    • 支持更复杂的权限规则
    • 方便实现其他相关功能

如果确实需要使用 JSON 存储,这里有一个优化的查询方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 如果必须使用 JSON 存储,可以使用 JSON_SEARCH 来提高效率
func GetProductListWithJSON(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

// 使用 JSON_SEARCH 而不是 JSON_CONTAINS
err := db.Where("view_type = 1 OR (view_type = 2 AND JSON_SEARCH(customer_manager, 'one', ?) IS NOT NULL)",
accountID).
Find(&products).Error

if err != nil {
return nil, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, nil
}

性能优化建议:

  1. 无论选择哪种方案,都要建立适当的索引:

    1
    2
    3
    4
    5
    6
    -- 关联表方案的索引
    CREATE INDEX idx_product_visibility ON product_visibilities(product_id, account_id);

    -- JSON 方案的索引(如果使用 MySQL 5.7+)
    ALTER TABLE products ADD INDEX idx_view_type (view_type);
    ALTER TABLE products ADD INDEX idx_customer_manager ((CAST(customer_manager AS CHAR(512))));
  2. 考虑添加缓存:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    func GetProductListWithCache(accountID string) ([]Product, error) {
    cacheKey := fmt.Sprintf("product_list:%s", accountID)

    // 尝试从缓存获取
    if cached, found := cache.Get(cacheKey); found {
    return cached.([]Product), nil
    }

    // 从数据库查询
    products, err := GetProductList(accountID)
    if err != nil {
    return nil, err
    }

    // 存入缓存
    cache.Set(cacheKey, products, time.Minute*5)

    return products, nil
    }

总的来说,我建议使用关联表方案,因为它提供了更好的性能、维护性和扩展性。如果业务确实需要使用 JSON 存储,那么要注意:

  • 控制 JSON 数据的大小
  • 合理使用索引
  • 考虑添加缓存机制
  • 定期监控查询性能

问题是我的一个产品如果是指定客户可以查看,那么他指定的客户不止一个,可能会有多个用户,比如某个产品指定了10个客户。你现在觉得用什么方案比较好

对于一个产品指定多个客户的场景,我依然建议使用关联表的方案,因为这是处理多对多关系的最佳实践。以下是详细的实现方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 产品表
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
// 其他产品字段...
}

// 产品-客户关联表
type ProductCustomer struct {
ID uint `gorm:"primaryKey"`
ProductID uint `gorm:"index"` // 产品ID
AccountID string `gorm:"index"` // 客户账号ID
CreatedAt time.Time // 创建时间
}
  1. 处理Excel上传的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
db := GetDB()

// 1. 读取Excel文件
src, err := file.Open()
if err != nil {
return errors.New("无法打开文件")
}
defer src.Close()

// 2. 保存文件到OSS
ossPath, err := uploadToOSS(src)
if err != nil {
return errors.New("文件上传OSS失败")
}

// 3. 解析Excel
f, err := excelize.OpenReader(src)
if err != nil {
return errors.New("无法解析Excel文件")
}
defer f.Close()

rows, err := f.GetRows("Sheet1")
if err != nil {
return errors.New("读取Excel内容失败")
}

// 开启事务
tx := db.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()

// 4. 更新产品为指定客户可见
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("view_type", 2).Error; err != nil {
tx.Rollback()
return err
}

// 5. 删除旧的关联记录
if err := tx.Where("product_id = ?", productID).
Delete(&ProductCustomer{}).Error; err != nil {
tx.Rollback()
return err
}

// 6. 批量插入新的关联记录
batchSize := 100
var batch []ProductCustomer

// 跳过表头
for i := 1; i < len(rows); i++ {
row := rows[i]
if len(row) < 2 {
continue
}

batch = append(batch, ProductCustomer{
ProductID: productID,
AccountID: row[1], // 使用第二列的账户ID
CreatedAt: time.Now(),
})

// 批量插入
if len(batch) >= batchSize {
if err := tx.CreateInBatches(batch, batchSize).Error; err != nil {
tx.Rollback()
return err
}
batch = batch[:0]
}
}

// 插入剩余的记录
if len(batch) > 0 {
if err := tx.CreateInBatches(batch, batchSize).Error; err != nil {
tx.Rollback()
return err
}
}

return tx.Commit().Error
}
  1. 查询产品列表的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func GetProductList(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

err := db.Distinct("products.*").
Select("products.*").
Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
Where(
db.Where("products.view_type = ?", 1).Or(
db.Where("products.view_type = ? AND product_customers.account_id = ?", 2, accountID),
),
).
Find(&products).Error

if err != nil {
return nil, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, nil
}
  1. 添加必要的索引:
1
2
-- 在 product_customers 表上创建联合索引
CREATE INDEX idx_product_customer ON product_customers(product_id, account_id);
  1. 如果需要分页查询:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type Pagination struct {
PageNum int
PageSize int
}

func GetProductListWithPagination(accountID string, page Pagination) ([]Product, int64, error) {
db := GetDB()
var products []Product
var total int64

// 构建基础查询
query := db.Model(&Product{}).
Distinct("products.*").
Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
Where(
db.Where("products.view_type = ?", 1).Or(
db.Where("products.view_type = ? AND product_customers.account_id = ?", 2, accountID),
),
)

// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}

// 分页查询
err := query.Offset((page.PageNum - 1) * page.PageSize).
Limit(page.PageSize).
Find(&products).Error

if err != nil {
return nil, 0, fmt.Errorf("查询产品列表失败: %w", err)
}

return products, total, nil
}
  1. 可以添加缓存优化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func GetProductListWithCache(accountID string) ([]Product, error) {
cacheKey := fmt.Sprintf("product_list:%s", accountID)

// 从缓存获取
if cached, found := cache.Get(cacheKey); found {
return cached.([]Product), nil
}

// 从数据库查询
products, err := GetProductList(accountID)
if err != nil {
return nil, err
}

// 设置缓存,5分钟过期
cache.Set(cacheKey, products, 5*time.Minute)

return products, nil
}

使用关联表方案的优势:

  1. 性能优势:

    • 可以充分利用数据库索引
    • 支持高效的批量操作
    • 查询性能稳定,不受JSON数据大小影响
  2. 维护优势:

    • 方便修改单个客户的权限
    • 容易统计和分析权限数据
    • 数据结构清晰,易于调试
  3. 扩展优势:

    • 容易添加新的权限相关字段(如过期时间、授权时间等)
    • 支持更复杂的权限规则
    • 便于实现权限变更历史记录
  4. 操作优势:

    • 支持单个添加/删除客户权限
    • 方便实现权限批量操作
    • 便于实现权限查询和验证

建议:

  1. 定期清理无效的关联记录
  2. 根据实际情况调整批量插入的大小
  3. 考虑添加创建时间和更新时间字段
  4. 可以添加软删除功能
  5. 建议添加适当的缓存机制

这个方案可以很好地处理一个产品指定多个客户的场景,而且性能和可维护性都比较好。如果你有任何特殊需求或需要更详细的说明,请告诉我。

产品-客户关联表的字段AccountID怎么存储多个值,也就是多个用户?

产品-客户关联表不需要在 AccountID 字段存储多个值。关联表的设计思路是:一个产品对应多个用户,就在表中存储多条记录,每条记录对应一个用户。举个例子:

假设有一个产品ID为1的产品,需要指定给账号ID为”user001”、”user002”、”user003”这三个用户查看,那么在关联表中会存储3条记录:

1
2
3
4
5
6
-- product_customers 表的数据示例
| ID | ProductID | AccountID |
|----|-----------|-----------|
| 1 | 1 | user001 |
| 2 | 1 | user002 |
| 3 | 1 | user003 |

完整的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// 产品表
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
}

// 产品-客户关联表
type ProductCustomer struct {
ID uint `gorm:"primaryKey"`
ProductID uint `gorm:"index"`
AccountID string `gorm:"index"` // 存储单个用户的账号ID
CreatedAt time.Time
}

// 处理Excel上传
func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
db := GetDB()

// 开启事务
tx := db.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()

// 1. 读取Excel文件
f, err := excelize.OpenFile(file.Filename)
if err != nil {
tx.Rollback()
return err
}
defer f.Close()

// 2. 更新产品为指定客户可见
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("view_type", 2).Error; err != nil {
tx.Rollback()
return err
}

// 3. 删除该产品的所有旧关联记录
if err := tx.Where("product_id = ?", productID).
Delete(&ProductCustomer{}).Error; err != nil {
tx.Rollback()
return err
}

// 4. 读取Excel数据并创建新的关联记录
rows, err := f.GetRows("Sheet1")
if err != nil {
tx.Rollback()
return err
}

// 准备批量插入的数据
var customers []ProductCustomer
for i, row := range rows {
if i == 0 { // 跳过表头
continue
}
if len(row) < 2 {
continue
}

customers = append(customers, ProductCustomer{
ProductID: productID,
AccountID: row[1], // 使用第二列的账户ID
CreatedAt: time.Now(),
})
}

// 5. 批量插入新的关联记录
if len(customers) > 0 {
if err := tx.CreateInBatches(customers, 100).Error; err != nil {
tx.Rollback()
return err
}
}

return tx.Commit().Error
}

// 查询用户可见的产品列表
func GetProductList(accountID string) ([]Product, error) {
db := GetDB()
var products []Product

// 查询条件:
// 1. viewType = 1 的产品(所有人可见)
// 2. 或者在产品-客户关联表中有对应记录的产品
err := db.Distinct("products.*").
Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
Where("products.view_type = ? OR (products.view_type = ? AND product_customers.account_id = ?)",
1, 2, accountID).
Find(&products).Error

if err != nil {
return nil, err
}

return products, nil
}

// 查询某个产品的可见用户列表
func GetProductCustomers(productID uint) ([]string, error) {
db := GetDB()
var customers []ProductCustomer
var accountIDs []string

err := db.Where("product_id = ?", productID).
Select("account_id").
Find(&customers).Error

if err != nil {
return nil, err
}

for _, customer := range customers {
accountIDs = append(accountIDs, customer.AccountID)
}

return accountIDs, nil
}

// 添加单个用户的查看权限
func AddProductCustomer(productID uint, accountID string) error {
db := GetDB()

// 先检查是否已存在
var count int64
db.Model(&ProductCustomer{}).
Where("product_id = ? AND account_id = ?", productID, accountID).
Count(&count)

if count > 0 {
return nil // 已存在,直接返回
}

// 添加新记录
customer := ProductCustomer{
ProductID: productID,
AccountID: accountID,
CreatedAt: time.Now(),
}

return db.Create(&customer).Error
}

// 删除单个用户的查看权限
func RemoveProductCustomer(productID uint, accountID string) error {
db := GetDB()

return db.Where("product_id = ? AND account_id = ?",
productID, accountID).
Delete(&ProductCustomer{}).Error
}

这种设计的优点:

  1. 数据结构清晰:

    • 每条记录代表一个产品-用户的关联关系
    • 便于理解和维护
  2. 操作灵活:

    • 可以方便地添加/删除单个用户的权限
    • 支持批量操作
    • 方便查询某个产品的所有可见用户
  3. 查询效率高:

    • 可以使用索引优化查询
    • 支持各种复杂的查询条件
  4. 扩展性好:

    • 可以轻松添加更多字段(如权限级别、过期时间等)
    • 支持添加权限变更历史记录
    • 方便实现权限审计功能

使用建议:

  1. 创建合适的索引:

    1
    2
    CREATE INDEX idx_product_customer ON product_customers(product_id, account_id);
    CREATE INDEX idx_account_id ON product_customers(account_id);
  2. 考虑添加软删除功能:

    1
    2
    3
    4
    5
    6
    7
    type ProductCustomer struct {
    ID uint `gorm:"primaryKey"`
    ProductID uint `gorm:"index"`
    AccountID string `gorm:"index"`
    CreatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
    }
  3. 可以添加批量操作的方法:

    1
    2
    3
    4
    5
    6
    7
    func BatchAddProductCustomers(productID uint, accountIDs []string) error {
    // 批量添加实现
    }

    func BatchRemoveProductCustomers(productID uint, accountIDs []string) error {
    // 批量删除实现
    }

这种设计可以很好地满足你的需求,既保证了数据的完整性,又提供了良好的性能和可维护性。

假设有一个产品ID为1的产品,需要指定给账号ID为”user001”、”user002”、”user003”这三个用户查看,那么在关联表中会存储3条记录这样设计的查询效率怎么样呢

对于这种设计,让我分析一下查询效率和优化方案:

  1. 查询效率分析:
1
2
3
4
5
6
7
-- 当用户查询可见产品列表时的SQL语句大致为:
SELECT DISTINCT products.*
FROM products
LEFT JOIN product_customers
ON products.id = product_customers.product_id
WHERE products.view_type = 1
OR (products.view_type = 2 AND product_customers.account_id = 'user001');

可能存在的性能问题:

  1. 表数据量大时,JOIN 操作可能会影响性能
  2. 需要使用 DISTINCT 去重
  3. 关联表记录数量会随着产品和用户的增加呈线性增长

让我们看看优化方案:

方案一:使用JSON字段存储(适合用户量不大的情况)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 产品表
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
ViewType int `gorm:"default:1"` // 1:全量可见 2:指定客户可见
CustomerIDs string `gorm:"type:json"` // 存储用户ID数组的JSON
}

// 处理Excel上传
func HandleCustomerUpload(file *multipart.FileHeader, productID uint) error {
// ... 文件读取逻辑 ...

// 收集用户ID
var accountIDs []string
for i, row := range rows {
if i == 0 { continue } // 跳过表头
if len(row) < 2 { continue }
accountIDs = append(accountIDs, row[1])
}

// 转换为JSON
customerIDsJSON, err := json.Marshal(accountIDs)
if err != nil {
return err
}

// 更新数据库
return db.Model(&Product{}).
Where("id = ?", productID).
Updates(map[string]interface{}{
"view_type": 2,
"customer_ids": string(customerIDsJSON),
}).Error
}

// 查询产品列表
func GetProductList(accountID string) ([]Product, error) {
var products []Product

err := db.Where("view_type = 1 OR (view_type = 2 AND JSON_CONTAINS(customer_ids, ?)))",
fmt.Sprintf("\"%s\"", accountID)).
Find(&products).Error

return products, err
}

方案二:使用位图标记(适合用户量固定且较小的情况)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 产品表
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
ViewType int `gorm:"default:1"`
CustomerBits []byte `gorm:"type:blob"` // 使用位图存储用户权限
}

// 用户ID到位置的映射
var userIDToPosition map[string]int

// 设置用户权限
func SetProductCustomers(productID uint, accountIDs []string) error {
// 创建位图
customerBits := make([]byte, (len(userIDToPosition)+7)/8)

// 设置对应位
for _, accountID := range accountIDs {
if pos, ok := userIDToPosition[accountID]; ok {
bytePos := pos / 8
bitPos := pos % 8
customerBits[bytePos] |= 1 << bitPos
}
}

return db.Model(&Product{}).
Where("id = ?", productID).
Updates(map[string]interface{}{
"view_type": 2,
"customer_bits": customerBits,
}).Error
}

方案三:分区表(适合大规模数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 按产品ID范围分区
CREATE TABLE product_customers (
id BIGINT PRIMARY KEY,
product_id BIGINT,
account_id VARCHAR(64),
created_at TIMESTAMP
) PARTITION BY RANGE (product_id) (
PARTITION p0 VALUES LESS THAN (1000),
PARTITION p1 VALUES LESS THAN (2000),
PARTITION p2 VALUES LESS THAN (3000),
-- ...
PARTITION pmax VALUES LESS THAN MAXVALUE
);

方案四:使用缓存优化(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type ProductService struct {
db *gorm.DB
cache *redis.Client
}

func (s *ProductService) GetProductList(accountID string) ([]Product, error) {
// 1. 尝试从缓存获取
cacheKey := fmt.Sprintf("product_list:%s", accountID)
if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil {
var products []Product
if err := json.Unmarshal([]byte(cached), &products); err == nil {
return products, nil
}
}

// 2. 从数据库查询
var products []Product
err := s.db.Distinct("products.*").
Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
Where("products.view_type = ? OR (products.view_type = ? AND product_customers.account_id = ?)",
1, 2, accountID).
Find(&products).Error

if err != nil {
return nil, err
}

// 3. 存入缓存
if productsJSON, err := json.Marshal(products); err == nil {
s.cache.Set(ctx, cacheKey, productsJSON, time.Minute*5)
}

return products, nil
}

性能优化建议:

  1. 索引优化:

    1
    2
    3
    4
    -- 创建复合索引
    CREATE INDEX idx_product_account ON product_customers(product_id, account_id);
    -- 创建视图类型索引
    CREATE INDEX idx_view_type ON products(view_type);
  2. 查询优化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 分页查询
    func GetProductListWithPagination(accountID string, page, pageSize int) ([]Product, error) {
    var products []Product
    offset := (page - 1) * pageSize

    return db.Distinct("products.*").
    Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
    Where("products.view_type = ? OR (products.view_type = ? AND product_customers.account_id = ?)",
    1, 2, accountID).
    Offset(offset).
    Limit(pageSize).
    Find(&products).Error
    }
  3. 缓存策略:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 使用Redis的Set结构存储用户权限
    func cacheProductCustomers(productID uint, accountIDs []string) error {
    key := fmt.Sprintf("product_customers:%d", productID)
    return redis.SAdd(key, accountIDs...).Err()
    }

    // 检查权限
    func checkPermission(productID uint, accountID string) bool {
    key := fmt.Sprintf("product_customers:%d", productID)
    return redis.SIsMember(key, accountID).Val()
    }

综合建议:

  1. 如果产品和用户数量不是特别大(比如产品小于10万,用户小于100万),建议使用原始的关联表方案 + 缓存:

    • 数据结构清晰
    • 维护简单
    • 配合缓存可以获得很好的性能
  2. 如果数据量特别大:

    • 考虑分库分表
    • 使用位图存储
    • 或者采用其他NoSQL解决方案
  3. 针对原方案的优化:

    • 添加适当的索引
    • 实现合理的缓存策略
    • 使用分页查询
    • 定期清理无效数据
  4. 监控和优化:

    • 监控慢查询
    • 定期分析执行计划
    • 根据实际情况调整索引

选择哪种方案主要取决于:

  1. 数据规模
  2. 访问模式
  3. 实时性要求
  4. 维护成本

建议从最简单的关联表方案开始,配合缓存使用,当发现性能问题时再进行优化。

如果使用关联表方案配合缓存使用的话,那么他们之间的数据一致性怎么保证?

在使用关联表配合缓存时,确实需要考虑数据一致性的问题。让我展示一个完整的解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
type ProductService struct {
db *gorm.DB
redis *redis.Client
}

// 缓存key的常量定义
const (
// 产品列表缓存key:PREFIX:用户ID
ProductListCacheKeyPrefix = "product:list:"
// 产品权限缓存key:PREFIX:产品ID
ProductPermissionCacheKeyPrefix = "product:permission:"
// 缓存过期时间
CacheExpiration = time.Minute * 30
)

// 生成产品列表缓存key
func generateProductListKey(accountID string) string {
return ProductListCacheKeyPrefix + accountID
}

// 生成产品权限缓存key
func generateProductPermissionKey(productID uint) string {
return ProductPermissionCacheKeyPrefix + strconv.FormatUint(uint64(productID), 10)
}

// 处理Excel上传
func (s *ProductService) HandleCustomerUpload(ctx context.Context, file *multipart.FileHeader, productID uint) error {
// 开启事务
tx := s.db.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()

// 读取Excel文件并验证
customers, err := s.readAndValidateExcel(file)
if err != nil {
tx.Rollback()
return err
}

// 1. 更新产品查看类型
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("view_type", 2).Error; err != nil {
tx.Rollback()
return err
}

// 2. 删除旧的关联记录
if err := tx.Where("product_id = ?", productID).
Delete(&ProductCustomer{}).Error; err != nil {
tx.Rollback()
return err
}

// 3. 批量插入新的关联记录
if err := tx.CreateInBatches(customers, 100).Error; err != nil {
tx.Rollback()
return err
}

// 提交事务
if err := tx.Commit().Error; err != nil {
return err
}

// 4. 删除相关缓存
if err := s.invalidateCache(ctx, productID, customers); err != nil {
// 记录日志,但不影响业务
log.Printf("Failed to invalidate cache: %v", err)
}

return nil
}

// 清除相关缓存
func (s *ProductService) invalidateCache(ctx context.Context, productID uint, customers []ProductCustomer) error {
pipe := s.redis.Pipeline()

// 1. 删除产品权限缓存
permissionKey := generateProductPermissionKey(productID)
pipe.Del(ctx, permissionKey)

// 2. 删除所有相关用户的产品列表缓存
for _, customer := range customers {
listKey := generateProductListKey(customer.AccountID)
pipe.Del(ctx, listKey)
}

// 3. 执行管道命令
_, err := pipe.Exec(ctx)
return err
}

// 查询产品列表
func (s *ProductService) GetProductList(ctx context.Context, accountID string) ([]Product, error) {
// 1. 尝试从缓存获取
products, err := s.getProductListFromCache(ctx, accountID)
if err == nil {
return products, nil
}

// 2. 缓存未命中,从数据库查询
products, err = s.getProductListFromDB(accountID)
if err != nil {
return nil, err
}

// 3. 更新缓存(使用管道)
if err := s.cacheProductList(ctx, accountID, products); err != nil {
// 记录日志,但不影响业务
log.Printf("Failed to cache product list: %v", err)
}

return products, nil
}

// 从缓存获取产品列表
func (s *ProductService) getProductListFromCache(ctx context.Context, accountID string) ([]Product, error) {
key := generateProductListKey(accountID)
data, err := s.redis.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}

var products []Product
if err := json.Unmarshal(data, &products); err != nil {
return nil, err
}

return products, nil
}

// 从数据库获取产品列表
func (s *ProductService) getProductListFromDB(accountID string) ([]Product, error) {
var products []Product
err := s.db.Distinct("products.*").
Joins("LEFT JOIN product_customers ON products.id = product_customers.product_id").
Where("products.view_type = ? OR (products.view_type = ? AND product_customers.account_id = ?)",
1, 2, accountID).
Find(&products).Error
return products, err
}

// 缓存产品列表
func (s *ProductService) cacheProductList(ctx context.Context, accountID string, products []Product) error {
data, err := json.Marshal(products)
if err != nil {
return err
}

key := generateProductListKey(accountID)
return s.redis.Set(ctx, key, data, CacheExpiration).Err()
}

// 检查用户是否有权限查看产品
func (s *ProductService) CheckPermission(ctx context.Context, productID uint, accountID string) (bool, error) {
// 1. 尝试从缓存获取权限信息
hasPermission, err := s.checkPermissionFromCache(ctx, productID, accountID)
if err == nil {
return hasPermission, nil
}

// 2. 缓存未命中,从数据库查询
hasPermission, err = s.checkPermissionFromDB(productID, accountID)
if err != nil {
return false, err
}

// 3. 更新缓存
if err := s.cachePermission(ctx, productID, accountID, hasPermission); err != nil {
log.Printf("Failed to cache permission: %v", err)
}

return hasPermission, nil
}

// 错误处理中间件
func (s *ProductService) withRetry(operation func() error) error {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
err := operation()
if err == nil {
return nil
}
if i == maxRetries-1 {
return err
}
time.Sleep(time.Millisecond * 100 * time.Duration(i+1))
}
return nil
}

数据一致性保证的关键点:

  1. 缓存更新策略:使用 Cache-Aside 模式

    1
    2
    3
    4
    5
    6
    7
    8
    // 写操作
    1. 更新数据库
    2. 删除相关缓存

    // 读操作
    1. 查询缓存
    2. 缓存未命中时查询数据库
    3. 更新缓存
  2. 事务处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 更新数据时使用事务
    tx := db.Begin()
    defer func() {
    if r := recover(); r != nil {
    tx.Rollback()
    }
    }()

    // 数据库操作...

    if err := tx.Commit().Error; err != nil {
    tx.Rollback()
    return err
    }

    // 删除缓存
  3. 缓存失效策略:

    1
    2
    3
    4
    5
    6
    7
    // 设置合理的过期时间
    const CacheExpiration = time.Minute * 30

    // 主动删除缓存的情况
    1. 数据更新时
    2. 数据删除时
    3. 发生错误时
  4. 并发控制:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 使用分布式锁处理并发更新
    func (s *ProductService) UpdateWithLock(ctx context.Context, productID uint, update func() error) error {
    lock := s.redisLock.NewLock(fmt.Sprintf("lock:product:%d", productID))
    if err := lock.Lock(); err != nil {
    return err
    }
    defer lock.Unlock()

    return update()
    }
  5. 错误处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 缓存操作失败时的重试机制
    func (s *ProductService) withRetry(operation func() error) error {
    maxRetries := 3
    for i := 0; i < maxRetries; i++ {
    if err := operation(); err == nil {
    return nil
    }
    time.Sleep(time.Millisecond * 100 * time.Duration(i+1))
    }
    return errors.New("operation failed after retries")
    }
  6. 监控和告警:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 添加监控指标
    type Metrics struct {
    CacheHits prometheus.Counter
    CacheMisses prometheus.Counter
    DatabaseErrors prometheus.Counter
    CacheErrors prometheus.Counter
    InconsistencyDetected prometheus.Counter
    }

    // 在关键操作点收集指标
    func (s *ProductService) recordMetrics(metric prometheus.Counter) {
    metric.Inc()
    }

最佳实践建议:

  1. 缓存设计:

    • 设置合理的过期时间
    • 使用前缀区分不同类型的缓存
    • 实现缓存预热机制
  2. 错误处理:

    • 缓存操作错误不应影响主流程
    • 实现重试机制
    • 记录详细的错误日志
  3. 监控和维护:

    • 监控缓存命中率
    • 监控数据一致性
    • 定期清理无效缓存
  4. 性能优化:

    • 使用管道批量处理缓存操作
    • 合理设置缓存大小
    • 实现缓存预热机制
  5. 其他建议:

    • 定期验证数据一致性
    • 实现缓存降级机制
    • 保持代码的可测试性

这样的设计可以在保证数据一致性的同时,提供良好的性能和可维护性。当然,具体的实现还需要根据实际业务需求进行调整。

产品-客户关联表建表语句麻烦你写一下,还有这个需求牵涉到的其他的字段添加的sql语句也麻烦你写一下

好的,我来编写相关的SQL语句。这里包括产品表的修改和产品-客户关联表的创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
-- 1. 修改产品表,添加 view_type 字段
ALTER TABLE products
ADD COLUMN view_type tinyint(1) NOT NULL DEFAULT 1 COMMENT '查看权限类型:1全量可见 2指定客户可见';

-- 2. 创建产品-客户关联表
CREATE TABLE `product_customers` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint unsigned NOT NULL COMMENT '产品ID',
`account_id` varchar(64) NOT NULL COMMENT '客户账号ID',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`created_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除 1已删除',
PRIMARY KEY (`id`),
KEY `idx_product_account` (`product_id`, `account_id`), -- 复合索引,用于权限查询
KEY `idx_account` (`account_id`), -- 单独的账号索引,用于查询用户可见产品
KEY `idx_created_at` (`created_at`), -- 创建时间索引,用于按时间查询
CONSTRAINT `fk_product_customers_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE -- 外键约束,产品删除时级联删除权限记录
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品-客户关联表';

-- 3. 创建文件上传记录表(用于记录Excel上传历史)
CREATE TABLE `product_customer_uploads` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint unsigned NOT NULL COMMENT '产品ID',
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
`file_path` varchar(255) NOT NULL COMMENT 'OSS文件路径',
`file_size` bigint NOT NULL COMMENT '文件大小(字节)',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1成功 2失败',
`error_msg` text DEFAULT NULL COMMENT '错误信息',
`affected_rows` int DEFAULT 0 COMMENT '影响的记录数',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`created_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除 1已删除',
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_uploads_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品客户Excel上传记录表';

-- 4. 创建操作日志表(用于记录权限变更历史)
CREATE TABLE `product_customer_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint unsigned NOT NULL COMMENT '产品ID',
`account_id` varchar(64) NOT NULL COMMENT '客户账号ID',
`operation_type` tinyint(1) NOT NULL COMMENT '操作类型:1添加权限 2删除权限',
`operation_desc` varchar(255) DEFAULT NULL COMMENT '操作描述',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` varchar(64) DEFAULT NULL COMMENT '操作人',
PRIMARY KEY (`id`),
KEY `idx_product_account` (`product_id`, `account_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品客户权限变更日志表';

-- 5. 创建必要的索引(如果需要)
-- 如果产品表没有创建时间索引,添加它
ALTER TABLE products
ADD INDEX idx_products_created_at (created_at);

-- 给产品表的view_type添加索引
ALTER TABLE products
ADD INDEX idx_view_type (view_type);

-- 6. 添加注释(如果需要)
-- 为产品表的view_type字段添加注释
ALTER TABLE products
MODIFY COLUMN view_type tinyint(1) NOT NULL DEFAULT 1 COMMENT '查看权限类型:1全量可见 2指定客户可见';

相关的数据库设计说明:

  1. product_customers 表:

    • 使用软删除(is_deleted字段)
    • 添加创建和更新时间
    • 添加操作人字段
    • 使用复合索引优化查询
    • 添加外键约束保证数据完整性
  2. product_customer_uploads 表:

    • 记录文件上传历史
    • 保存文件在OSS的路径
    • 记录处理结果和错误信息
    • 记录影响的记录数
  3. product_customer_logs 表:

    • 记录所有权限变更操作
    • 便于审计和问题追踪
    • 保存详细的操作信息

其他可能需要的SQL操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 查询某个用户可见的产品列表
SELECT DISTINCT p.*
FROM products p
LEFT JOIN product_customers pc ON p.id = pc.product_id
WHERE p.view_type = 1
OR (p.view_type = 2 AND pc.account_id = 'user001' AND pc.is_deleted = 0);

-- 查询某个产品的可见用户列表
SELECT pc.account_id
FROM product_customers pc
WHERE pc.product_id = 1
AND pc.is_deleted = 0;

-- 查询文件上传历史
SELECT *
FROM product_customer_uploads
WHERE product_id = 1
ORDER BY created_at DESC;

-- 查询权限变更历史
SELECT *
FROM product_customer_logs
WHERE product_id = 1
ORDER BY created_at DESC;

索引使用建议:

  1. 主要查询模式:

    1
    2
    3
    4
    5
    6
    7
    -- 这个查询最频繁,确保有合适的索引
    SELECT ... FROM products
    WHERE view_type = 1 OR (view_type = 2 AND EXISTS (
    SELECT 1 FROM product_customers
    WHERE product_id = products.id
    AND account_id = ?
    ));
  2. 批量操作时的索引注意事项:

    1
    2
    3
    4
    -- 批量插入前临时禁用唯一索引检查
    SET UNIQUE_CHECKS=0;
    -- 批量插入操作
    SET UNIQUE_CHECKS=1;
  3. 定期维护建议:

    1
    2
    3
    4
    5
    -- 分析表
    ANALYZE TABLE product_customers;

    -- 优化表
    OPTIMIZE TABLE product_customers;

这些SQL语句提供了完整的数据库结构支持,包括:

  1. 基本的权限控制
  2. 文件上传记录
  3. 操作日志
  4. 必要的索引优化
  5. 数据完整性保证

你可以根据实际需求调整字段类型、长度和索引设计。如果还需要其他的SQL语句或者有特殊的查询需求,请告诉我。

还需要一个SQL将全表的历史产品查出来,将他们的数据viewType设置为全量客户

好的,我来写这个SQL语句。为了安全起见,我会提供两种方案:一种是直接更新,另一种是分批更新。

  1. 直接更新所有历史产品(适用于数据量较小的情况):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 直接更新所有产品的viewType为1(全量客户可见)
UPDATE products
SET view_type = 1,
updated_at = NOW(),
updated_by = 'SYSTEM' -- 记录操作人
WHERE 1=1; -- 如果需要特定条件可以在这里添加

-- 清理关联表中的数据(可选)
DELETE FROM product_customers
WHERE product_id IN (
SELECT id
FROM products
WHERE view_type = 1
);

-- 记录批量操作日志(建议添加)
INSERT INTO product_customer_logs
(product_id, account_id, operation_type, operation_desc, created_by)
SELECT
id,
'ALL', -- 表示所有用户
2, -- 操作类型:删除权限
'批量更新产品为全量可见',
'SYSTEM'
FROM products;
  1. 分批更新方案(推荐,适用于大数据量):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
-- 创建临时存储过程进行分批更新
DELIMITER //

CREATE PROCEDURE batch_update_products_view_type()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE batch_size INT DEFAULT 1000; -- 每批处理的数量
DECLARE last_id BIGINT DEFAULT 0;

-- 创建临时表记录处理进度
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_process_log (
id BIGINT PRIMARY KEY,
processed BOOLEAN DEFAULT FALSE
);

-- 插入所有需要处理的产品ID
INSERT INTO tmp_process_log (id)
SELECT id FROM products;

-- 循环处理
REPEAT
-- 开启事务
START TRANSACTION;

-- 更新一批数据
UPDATE products
SET view_type = 1,
updated_at = NOW(),
updated_by = 'SYSTEM'
WHERE id IN (
SELECT id
FROM tmp_process_log
WHERE processed = FALSE
AND id > last_id
ORDER BY id
LIMIT batch_size
);

-- 更新处理状态
UPDATE tmp_process_log
SET processed = TRUE
WHERE id IN (
SELECT id
FROM products
WHERE id > last_id
ORDER BY id
LIMIT batch_size
);

-- 记录最后处理的ID
SELECT MAX(id) INTO last_id
FROM tmp_process_log
WHERE processed = TRUE;

-- 记录操作日志
INSERT INTO product_customer_logs
(product_id, account_id, operation_type, operation_desc, created_by)
SELECT
id,
'ALL',
2,
'批量更新产品为全量可见',
'SYSTEM'
FROM products
WHERE id > (last_id - batch_size)
AND id <= last_id;

-- 提交事务
COMMIT;

-- 暂停一小段时间,避免对数据库造成过大压力
DO SLEEP(0.1);

UNTIL last_id IS NULL OR (SELECT COUNT(*) FROM tmp_process_log WHERE processed = FALSE) = 0
END REPEAT;

-- 清理关联表数据
DELETE FROM product_customers
WHERE product_id IN (
SELECT id
FROM products
WHERE view_type = 1
);

-- 删除临时表
DROP TEMPORARY TABLE IF EXISTS tmp_process_log;
END //

DELIMITER ;

-- 执行存储过程
CALL batch_update_products_view_type();

-- 删除存储过程
DROP PROCEDURE IF EXISTS batch_update_products_view_type;
  1. 更新前后的验证SQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 更新前统计
SELECT
view_type,
COUNT(*) as count
FROM products
GROUP BY view_type;

-- 检查是否有关联数据
SELECT
COUNT(*) as relation_count
FROM product_customers;

-- 执行更新后的验证
SELECT
view_type,
COUNT(*) as count
FROM products
GROUP BY view_type;

-- 检查关联表是否已清理
SELECT
COUNT(*) as remaining_relations
FROM product_customers;

-- 检查操作日志
SELECT
operation_type,
COUNT(*) as log_count
FROM product_customer_logs
WHERE operation_desc = '批量更新产品为全量可见'
GROUP BY operation_type;
  1. 回滚脚本(以防需要回滚):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 注意:使用前需要确保有数据备份
-- 创建备份表
CREATE TABLE products_backup AS SELECT * FROM products;
CREATE TABLE product_customers_backup AS SELECT * FROM product_customers;

-- 如果需要回滚
-- 恢复产品表
UPDATE products p
INNER JOIN products_backup pb ON p.id = pb.id
SET p.view_type = pb.view_type,
p.updated_at = pb.updated_at,
p.updated_by = pb.updated_by;

-- 恢复关联表
INSERT INTO product_customers
SELECT * FROM product_customers_backup;

-- 记录回滚操作
INSERT INTO product_customer_logs
(product_id, account_id, operation_type, operation_desc, created_by)
SELECT
id,
'ALL',
1,
'回滚批量更新操作',
'SYSTEM'
FROM products;

使用建议:

  1. 在执行更新前:

    • 备份相关表的数据
    • 在测试环境验证SQL
    • 选择业务低峰期执行
    • 预估执行时间
  2. 执行更新时:

    • 监控数据库负载
    • 记录详细的操作日志
    • 准备回滚方案
  3. 执行更新后:

    • 验证数据一致性
    • 清理相关缓存
    • 观察系统性能
  4. 其他注意事项:

    • 考虑业务影响
    • 准备回滚方案
    • 通知相关团队

建议使用分批更新方案,这样可以:

  1. 避免长事务
  2. 控制系统负载
  3. 便于监控进度
  4. 出问题时容易处理

如果还需要其他SQL或者有特殊需求,请告诉我。

产品-客户关联表的这两段代码有用吗?KEY idx_product_account (product_id, account_id), – 复合索引,用于权限查询 KEY idx_account (account_id), – 单独的账号索引,用于查询用户可见产品

这两个索引的作用性需要根据具体的查询场景来分析。让我们看看常见的查询场景:

  1. 查询某个用户可见的产品列表:
    1
    2
    3
    4
    5
    SELECT DISTINCT products.* 
    FROM products
    LEFT JOIN product_customers ON products.id = product_customers.product_id
    WHERE products.view_type = 1
    OR (products.view_type = 2 AND product_customers.account_id = 'user001');
    这个查询中:
  • idx_account (account_id) 索引会被用到,因为我们需要根据 account_id 来查找记录
  1. 查询某个产品的可见用户列表:
    1
    2
    3
    SELECT account_id 
    FROM product_customers
    WHERE product_id = 1;
    这个查询中:
  • idx_product_account (product_id, account_id) 索引会被用到,因为是先根据 product_id 查询

所以这两个索引的选择应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE `product_customers` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint unsigned NOT NULL COMMENT '产品ID',
`account_id` varchar(64) NOT NULL COMMENT '客户账号ID',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`created_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除 1已删除',
PRIMARY KEY (`id`),
-- 保留这个复合索引,因为它可以同时支持按product_id查询和按(product_id, account_id)组合查询
KEY `idx_product_account` (`product_id`, `account_id`),
-- 可以考虑删除这个单独的account_id索引,因为查询时通常还需要用到view_type
-- KEY `idx_account` (`account_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_product_customers_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品-客户关联表';

-- 在products表上添加合适的索引
ALTER TABLE products
ADD INDEX idx_view_type (view_type);

更好的索引设计:

  1. 如果主要查询场景是查询用户可见的产品列表:

    1
    2
    3
    4
    5
    -- 在product_customers表上创建联合索引
    CREATE INDEX idx_account_product ON product_customers(account_id, product_id);

    -- 在products表上创建view_type索引
    CREATE INDEX idx_view_type ON products(view_type);
  2. 如果经常需要同时查询产品信息:

    1
    CREATE INDEX idx_product_account_status ON product_customers(product_id, account_id, is_deleted);

要判断索引是否有效,可以:

  1. 使用 EXPLAIN 分析查询:

    1
    2
    3
    4
    5
    EXPLAIN SELECT DISTINCT products.* 
    FROM products
    LEFT JOIN product_customers ON products.id = product_customers.product_id
    WHERE products.view_type = 1
    OR (products.view_type = 2 AND product_customers.account_id = 'user001');
  2. 查看索引使用情况:

    1
    SHOW INDEX FROM product_customers;
  3. 查看实际的查询性能:

    1
    2
    3
    -- 开启慢查询日志
    SET long_query_time = 1;
    SET slow_query_log = 1;

优化建议:

  1. 对于主查询,可以这样优化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    -- 先查询指定用户的产品ID
    WITH user_products AS (
    SELECT DISTINCT product_id
    FROM product_customers
    WHERE account_id = 'user001'
    AND is_deleted = 0
    )
    -- 然后查询产品信息
    SELECT p.*
    FROM products p
    WHERE p.view_type = 1
    OR (p.view_type = 2 AND p.id IN (SELECT product_id FROM user_products));
  2. 添加合适的索引:

    1
    2
    3
    4
    5
    6
    7
    -- 产品表
    ALTER TABLE products
    ADD INDEX idx_view_type_id (view_type, id);

    -- 关联表
    CREATE INDEX idx_account_product_status
    ON product_customers(account_id, product_id, is_deleted);
  3. 如果需要分页,可以这样优化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    -- 先获取ID列表
    WITH product_ids AS (
    SELECT p.id
    FROM products p
    LEFT JOIN product_customers pc ON p.id = pc.product_id AND pc.account_id = 'user001'
    WHERE p.view_type = 1 OR (p.view_type = 2 AND pc.id IS NOT NULL)
    LIMIT 10 OFFSET 0
    )
    -- 再查询详细信息
    SELECT p.*
    FROM products p
    JOIN product_ids pi ON p.id = pi.id;

所以总结一下:

  1. idx_product_account 是有用的,它支持按产品ID查询用户的场景
  2. idx_account 单独使用的场景较少,可以考虑删除
  3. 应该根据实际的查询场景来设计索引
  4. 必要时可以通过修改查询语句来更好地利用索引

推荐的最终索引设计:

1
2
3
4
5
6
7
CREATE TABLE `product_customers` (
... 其他字段 ...,
PRIMARY KEY (`id`),
KEY `idx_product_account_status` (`product_id`, `account_id`, `is_deleted`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_product_customers_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE
);

这样的设计可以满足大多数查询场景,并且不会维护过多的索引。

在第一个例子中,你说这个查询中:idx_product_account (product_id, account_id) 索引会被用到,因为是先根据 product_id 查询的意思是我们设置了这个为索引之后,mysql在查询的时候会自己去使用这个索引吗?

是的,你理解得对。在MySQL中,当你创建了索引后,MySQL查询优化器会自动判断是否使用索引来优化查询。

让我详细解释一下这个过程:

  1. 当你执行一个查询时,MySQL的查询优化器会:

    • 分析你的WHERE条件
    • 检查表中可用的索引
    • 评估使用不同索引的成本(比如扫描的行数)
    • 选择一个最优的执行计划
  2. 以这个索引为例:

    1
    CREATE INDEX idx_product_account ON table_name(product_id, account_id);
  3. 当你执行类似这样的查询:

    1
    2
    3
    SELECT * FROM table_name WHERE product_id = 1 AND account_id = 100;
    -- 或者
    SELECT * FROM table_name WHERE product_id = 1;

MySQL会发现:

  • 这个查询条件匹配索引的列
  • 使用索引可以减少需要扫描的行数
  • 所以会选择使用这个索引
  1. 你可以通过 EXPLAIN 命令来查看MySQL是否使用了索引:
    1
    EXPLAIN SELECT * FROM table_name WHERE product_id = 1;

不过需要注意的是:

  • MySQL不一定总是使用索引,如果优化器认为全表扫描更快,它会选择不使用索引
  • 索引的使用还要考虑前缀原则,比如在复合索引中,只有遵循最左前缀原则才会使用到索引
  • 某些操作符(如 LIKE ‘%abc’)可能会导致索引失效

所以简单来说,是的,MySQL会自动判断并使用合适的索引,但这个过程是由查询优化器根据具体情况来决定的。

我有一个接口现在需要实现以下的需求,我有一个产品列表,需要通过excel文件实现指定客户可以查看还是所有客户可以查看,现在默认是所有客户都可以查看。允许用户上传文件,在用户上传完文件之后就对excel的文件的格式进行校验,文件第一列手机号码,第二列账户id。如果有问题需要报错提示文件格式错误供前端使用,如果没问题就把文件数据拿出来存到数据库里面去,供文件列表接口使用判断某个用户能看到哪些产品列表;还需要把文件存到oss里面去,方便客户下载文件重新上传。项目语言为go语言。我希望使用关联表方案

我来帮你实现这个功能。我们可以分步骤来实现:

  1. 首先创建关联表的数据库结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 产品表(假设已存在)
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
-- 其他产品字段
);

-- 产品可见性关联表
CREATE TABLE product_visibility (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT,
phone VARCHAR(20),
account_id VARCHAR(50),
file_url VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id)
);
  1. 实现处理上传文件的接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
package handler

import (
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
)

type ProductVisibilityHandler struct {
db *gorm.DB
oss *oss.Client
}

// 文件上传响应结构
type UploadResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}

// 处理文件上传
func (h *ProductVisibilityHandler) UploadVisibilityFile(c *gin.Context) {
// 1. 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件上传失败",
})
return
}
defer file.Close()

// 2. 验证文件格式是否为excel
if !strings.HasSuffix(header.Filename, ".xlsx") {
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件格式错误,请上传xlsx格式文件",
})
return
}

// 3. 读取并验证Excel内容
xlsx, err := excelize.OpenReader(file)
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件读取失败",
})
return
}

// 4. 验证Excel格式
rows, err := xlsx.GetRows("Sheet1")
if err != nil {
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件格式错误",
})
return
}

// 验证表头
if len(rows) < 2 { // 至少包含表头和一行数据
c.JSON(400, UploadResponse{
Code: 400,
Message: "文件内容为空",
})
return
}

// 5. 上传文件到OSS
ossPath := "product-visibility/" + time.Now().Format("20060102") + "/" + header.Filename
err = h.uploadToOSS(file, ossPath)
if err != nil {
c.JSON(500, UploadResponse{
Code: 500,
Message: "文件保存失败",
})
return
}

// 6. 保存数据到数据库
visibilityRecords := make([]ProductVisibility, 0)
for i := 1; i < len(rows); i++ { // 跳过表头
row := rows[i]
if len(row) < 2 {
continue
}
record := ProductVisibility{
Phone: row[0],
AccountID: row[1],
FileURL: ossPath,
}
visibilityRecords = append(visibilityRecords, record)
}

// 批量插入数据
if err := h.db.Create(&visibilityRecords).Error; err != nil {
c.JSON(500, UploadResponse{
Code: 500,
Message: "数据保存失败",
})
return
}

c.JSON(200, UploadResponse{
Code: 200,
Message: "上传成功",
Data: gin.H{
"file_url": ossPath,
},
})
}

// 上传文件到OSS
func (h *ProductVisibilityHandler) uploadToOSS(file multipart.File, ossPath string) error {
bucket, err := h.oss.Bucket("your-bucket-name")
if err != nil {
return err
}
return bucket.PutObject(ossPath, file)
}

// 检查产品是否可见
func (h *ProductVisibilityHandler) CheckProductVisibility(productID int64, phone, accountID string) bool {
var count int64
h.db.Model(&ProductVisibility{}).
Where("product_id = ? AND (phone = ? OR account_id = ?)", productID, phone, accountID).
Count(&count)
return count > 0
}

// 获取产品列表的接口
func (h *ProductVisibilityHandler) GetProductList(c *gin.Context) {
phone := c.Query("phone")
accountID := c.Query("account_id")

var products []Product
query := h.db.Model(&Product{})

// 如果有指定用户的可见性限制,则进行筛选
if phone != "" || accountID != "" {
query = query.Joins("LEFT JOIN product_visibility pv ON products.id = pv.product_id").
Where("pv.phone = ? OR pv.account_id = ? OR pv.id IS NULL", phone, accountID)
}

if err := query.Find(&products).Error; err != nil {
c.JSON(500, gin.H{"error": "获取产品列表失败"})
return
}

c.JSON(200, products)
}
  1. 定义相关的模型结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ProductVisibility struct {
ID int64 `gorm:"primaryKey"`
ProductID int64 `gorm:"index"`
Phone string `gorm:"size:20"`
AccountID string `gorm:"size:50"`
FileURL string `gorm:"size:255"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}

type Product struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"size:255"`
// 其他产品字段
}
  1. 路由配置:
1
2
3
4
func SetupRoutes(r *gin.Engine, h *ProductVisibilityHandler) {
r.POST("/api/product/visibility/upload", h.UploadVisibilityFile)
r.GET("/api/products", h.GetProductList)
}

这个实现主要包含以下功能:

  1. 文件上传和格式验证
  2. 将文件保存到OSS
  3. 解析Excel文件内容
  4. 将数据保存到关联表
  5. 提供产品列表接口,支持根据用户权限过滤

使用说明:

  1. 前端上传Excel文件时,需要确保文件格式正确(第一列为手机号,第二列为账户ID)
  2. 文件会被保存到OSS中,方便后续下载和重新上传
  3. 产品列表接口会根据用户的手机号或账户ID来过滤可见的产品

需要注意的点:

  1. 需要proper的错误处理
  2. 可以添加更多的数据验证
  3. 可以添加事务处理
  4. 可以添加并发处理大文件的能力
  5. 可以添加日志记录

你可能需要根据实际情况调整以下内容:

  1. 数据库表结构
  2. OSS配置
  3. 错误处理方式
  4. 文件格式验证规则
  5. 接口返回格式

希望这个实现对你有帮助!如果需要更详细的说明或有任何问题,请随时询问。

我现在想查询某个用户可以看见的所有的产品怎么查询

我来帮你实现查询用户可见产品的功能。这里提供两种查询方案:

  1. 直接使用SQL查询的方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func (h *ProductVisibilityHandler) GetVisibleProducts(c *gin.Context) {
phone := c.Query("phone")
accountID := c.Query("account_id")

if phone == "" && accountID == "" {
c.JSON(400, gin.H{
"code": 400,
"message": "请提供手机号或账户ID",
})
return
}

var products []Product

// 查询SQL:
// 1. 查询专门针对该用户可见的产品
// 2. 查询所有用户都可见的产品(即在product_visibility表中没有记录的产品)
query := h.db.Table("products p").
Select("DISTINCT p.*").
Joins("LEFT JOIN product_visibility pv ON p.id = pv.product_id").
Where("pv.phone = ? OR pv.account_id = ? OR pv.id IS NULL", phone, accountID)

if err := query.Find(&products).Error; err != nil {
c.JSON(500, gin.H{
"code": 500,
"message": "查询失败",
"error": err.Error(),
})
return
}

c.JSON(200, gin.H{
"code": 200,
"message": "success",
"data": products,
})
}
  1. 使用GORM的方式更结构化的查询:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
type ProductService struct {
db *gorm.DB
}

type ProductResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
// 其他产品字段
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

func NewProductService(db *gorm.DB) *ProductService {
return &ProductService{db: db}
}

func (s *ProductService) GetVisibleProducts(phone, accountID string) ([]ProductResponse, error) {
var products []ProductResponse

query := s.db.Model(&Product{}).
Select("DISTINCT products.*").
Joins("LEFT JOIN product_visibility pv ON products.id = pv.product_id").
Where("pv.phone = ? OR pv.account_id = ? OR pv.id IS NULL", phone, accountID)

// 可以添加其他查询条件
// query = query.Where("products.status = ?", "active")

// 可以添加排序
query = query.Order("products.created_at DESC")

err := query.Find(&products).Error
if err != nil {
return nil, fmt.Errorf("查询产品失败: %w", err)
}

return products, nil
}

// 在handler中使用
func (h *ProductVisibilityHandler) GetVisibleProducts(c *gin.Context) {
phone := c.Query("phone")
accountID := c.Query("account_id")

if phone == "" && accountID == "" {
c.JSON(400, gin.H{
"code": 400,
"message": "请提供手机号或账户ID",
})
return
}

productService := NewProductService(h.db)
products, err := productService.GetVisibleProducts(phone, accountID)
if err != nil {
c.JSON(500, gin.H{
"code": 500,
"message": "查询失败",
"error": err.Error(),
})
return
}

c.JSON(200, gin.H{
"code": 200,
"message": "success",
"data": gin.H{
"total": len(products),
"list": products,
},
})
}
  1. 如果需要分页查询:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
type PageQuery struct {
Page int `form:"page" binding:"required,min=1"`
PageSize int `form:"page_size" binding:"required,min=1,max=100"`
Phone string `form:"phone"`
AccountID string `form:"account_id"`
}

func (s *ProductService) GetVisibleProductsWithPagination(query PageQuery) ([]ProductResponse, int64, error) {
var products []ProductResponse
var total int64

db := s.db.Model(&Product{}).
Select("DISTINCT products.*").
Joins("LEFT JOIN product_visibility pv ON products.id = pv.product_id").
Where("pv.phone = ? OR pv.account_id = ? OR pv.id IS NULL", query.Phone, query.AccountID)

// 先获取总数
err := db.Count(&total).Error
if err != nil {
return nil, 0, fmt.Errorf("计算总数失败: %w", err)
}

// 分页查询
offset := (query.Page - 1) * query.PageSize
err = db.Offset(offset).
Limit(query.PageSize).
Order("products.created_at DESC").
Find(&products).Error
if err != nil {
return nil, 0, fmt.Errorf("查询产品失败: %w", err)
}

return products, total, nil
}

// 在handler中使用分页查询
func (h *ProductVisibilityHandler) GetVisibleProductsWithPagination(c *gin.Context) {
var query PageQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(400, gin.H{
"code": 400,
"message": "参数错误",
"error": err.Error(),
})
return
}

productService := NewProductService(h.db)
products, total, err := productService.GetVisibleProductsWithPagination(query)
if err != nil {
c.JSON(500, gin.H{
"code": 500,
"message": "查询失败",
"error": err.Error(),
})
return
}

c.JSON(200, gin.H{
"code": 200,
"message": "success",
"data": gin.H{
"total": total,
"page": query.Page,
"page_size": query.PageSize,
"list": products,
},
})
}

这个实现的主要特点:

  1. 通过LEFT JOIN和WHERE条件来查询用户可见的产品
  2. 使用DISTINCT避免重复数据
  3. 提供了分页查询的支持
  4. 支持通过手机号或账户ID查询
  5. 结构化的错误处理
  6. 清晰的代码组织结构

使用说明:

  1. 基础查询:

    1
    GET /api/products/visible?phone=13800138000
  2. 分页查询:

    1
    GET /api/products/visible?phone=13800138000&page=1&page_size=10

你可以根据实际需求进行以下调整:

  1. 添加更多的查询条件(如产品状态、类别等)
  2. 添加排序选项
  3. 优化查询性能(添加适当的索引)
  4. 添加缓存机制
  5. 添加更多的数据验证

通过上传Excel文件来实现产品对用户可见性的控制

https://blog.jiejaitt.top/posts/cd8b1634.html

作者

JIeJaitt

发布于

2024-09-27

更新于

2024-10-07

许可协议

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×