feat: 初始提交云印享付费打印服务后端

This commit is contained in:
WuYuuuub
2026-03-29 11:50:43 +08:00
parent 5047380dd0
commit 7491635a75
36 changed files with 13323 additions and 0 deletions

801
architecture-design.md Normal file
View File

@@ -0,0 +1,801 @@
99# 云印享 - 付费打印服务系统架构设计
## 1. 项目概述
### 1.1 项目背景
构建一个提供付费打印服务的微信小程序系统,用户可通过小程序上传文档/图片/压缩包选择打印机并完成打印。系统包含用户端小程序、管理员端Windows客户端和后端服务。
### 1.2 核心功能需求
| 模块 | 功能点 |
|------|--------|
| 用户端小程序 | 上传文件(文档/图片/压缩包)、价目表展示、选择打印机、文件编辑(预览/水印/合并)、聊天记录选择文件、打印订单管理 |
| 管理员端 | 文库管理(上传资料供用户打印)、订单处理、打印机管理、用户管理 |
| 后端服务 | 用户认证、文件存储、订单处理、API网关 |
| Windows客户端 | 调用Windows API连接打印机、执行打印任务、文件预处理 |
### 1.3 技术栈
- **小程序端**Taro跨平台框架+ React技术栈
- **后端**Go + gRPC + MySQL + go-pay微信支付
- **Windows客户端**C# (.NET 8) + WPF
- **管理后台**React + Ant Design网页端
- **文件存储**:本地存储 + MinIO可选
- **通信**gRPC + WebSocket
---
## 2. 系统架构
### 2.1 整体架构图
```mermaid
flowchart TB
subgraph Client["客户端层"]
MiniApp["Taro微信小程序<br/>用户端"]
AdminWeb["React管理后台<br/>网页端"]
end
subgraph Backend["后端服务层"]
GRPC["gRPC Gateway<br/>:8080"]
Auth["认证服务"]
File["文件服务"]
Order["订单服务"]
Print["打印服务"]
Pay["支付服务<br/>go-pay"]
end
subgraph Data["数据层"]
MySQL["MySQL<br/>数据库"]
Redis["Redis<br/>缓存"]
MinIO["MinIO<br/>文件存储"]
end
subgraph PrintShop["打印店内部署"]
WinClient["C# WPF客户端<br/>打印管理程序"]
Printers["打印机列表"]
LocalFiles["本地文库"]
end
MiniApp -->|"HTTPS/gRPC"| GRPC
AdminWeb -->|"HTTPS/gRPC"| GRPC
WinClient -->|"gRPC| WebSocket"| GRPC
GRPC --> Auth
GRPC --> File
GRPC --> Order
GRPC --> Print
GRPC --> Pay
Auth --> Redis
Order --> MySQL
File --> MinIO
File --> MySQL
Pay --> WeChat["微信支付"]
WinClient --> Printers
WinClient --> LocalFiles
```
### 2.2 部署架构
```mermaid
flowchart LR
subgraph Internet["互联网"]
User["用户<br/>微信小程序"]
end
subgraph Cloud["云服务器"]
API_Server["Go API Server"]
DB["MySQL + Redis"]
File_Storage["文件存储服务"]
end
subgraph Local["打印店本地"]
PC["Windows电脑<br/>C# WPF客户端"]
Printer["打印机"]
end
User -->|"上传文件/下单"| Cloud
Cloud -->|"转发打印任务"| PC
PC -->|"执行打印"| Printer
```
---
## 3. 模块详细设计
### 3.1 微信小程序端
#### 3.1.1 页面结构
```
pages/
├── index/ # 首页
│ ├──价目表展示
│ ├──快捷上传入口
│ └──最近的打印历史
├── upload/ # 文件上传
│ ├──文件选择(支持聊天记录)
│ ├──上传进度
│ └──文件预览
├── editor/ # 文件编辑
│ ├──PDF预览
│ ├──水印设置
│ ├──页面合并
│ └──打印参数设置
├── printer/ # 打印机选择
│ ├──打印机列表
│ ├──打印机状态
│ └──价格显示
├── order/ # 订单管理
│ ├──订单列表
│ ├──订单详情
│ └──支付
├── library/ # 文库(用户浏览)
│ ├──分类列表
│ ├──文件搜索
│ └──文件详情
└── my/ # 个人中心
├──打印记录
├──账户余额
└──设置
```
#### 3.1.2 核心页面流程
```mermaid
flowchart TD
Start["首页"] --> Upload{"选择上传方式"}
Upload -->|"本地上传"| SelectFile["选择文件"]
Upload -->|"聊天记录"| ChatFile["从聊天选择"]
SelectFile --> Preview["文件预览"]
ChatFile --> Preview
Preview --> Edit{"需要编辑?"}
Edit -->|"是"| Editor["编辑页面"]
Edit -->|"否"| SelectPrinter["选择打印机"]
Editor --> SelectPrinter
SelectPrinter --> Confirm["确认订单"]
Confirm --> Pay["支付"]
Pay --> Success["下单成功"]
Success --> Notify["通知打印店"]
```
#### 3.1.3 文件编辑功能设计
| 功能 | 实现方式 | 说明 |
|------|----------|------|
| PDF预览 | pdf.js | 轻量级PDF预览支持缩放、翻页 |
| 添加水印 | 前端Canvas + 后端PDF处理 | 支持文字水印、位置、透明度 |
| 页面合并 | pdf-lib | 合并多个PDF页面 |
| 打印参数 | 份数、纸张大小、单双面、彩色/黑白 | 传递给Windows客户端 |
### 3.2 C# WPF打印客户端
#### 3.2.1 核心模块
```csharp
// 项目结构
CloudPrint.Client/
App.xaml #
Services/
PrintService.cs # Windows API
FileService.cs #
GrpcService.cs # gRPC通信服务
WebSocketService.cs # WebSocket备用通信
ViewModels/
MainViewModel.cs #
PrintQueueViewModel.cs #
LibraryViewModel.cs #
SettingsViewModel.cs #
Views/
MainWindow.xaml
PrintQueueView.xaml
LibraryView.xaml
SettingsView.xaml
Models/
PrintJob.cs
Printer.cs
Document.cs
Helpers/
WindowsPrintApi.cs # Windows打印API封装
PdfProcessor.cs # PDF处理
```
#### 3.2.2 Windows打印API集成
使用 P/Invoke 调用 Windows Print Spooler API
- `OpenPrinter`
- `StartDocPrinter`
- `StartPagePrinter`
- `WritePrinter`
- `EndPagePrinter`
- `EndDocPrinter`
- `ClosePrinter`
```csharp
// 核心打印服务类
public class PrintService
{
// 获取系统打印机列表
public List<PrinterInfo> GetPrinters();
// 获取打印机状态
public PrinterStatus GetPrinterStatus(string printerName);
// 提交打印任务
public string SubmitPrintJob(PrintJob job);
// 取消打印任务
public bool CancelPrintJob(string jobId);
// 获取打印任务状态
public PrintJobStatus GetJobStatus(string jobId);
}
```
#### 3.2.3 gRPC通信
```csharp
// 与后端gRPC服务通信
public class GrpcService
{
// 连接gRPC服务器
public async Task ConnectAsync(string address);
// 订阅打印任务流
public async Task SubscribePrintJobsAsync(string printerName);
// 发送状态更新
public async Task SendStatusUpdateAsync(PrintJobStatus status);
}
```
### 3.3 后端服务Go + gRPC + go-pay
#### 3.3.1 项目结构
```text
cloudprint-backend/
├── cmd/
│ └── server/
│ └── main.go # 入口
├── config/
│ └── config.go # 配置
├── internal/
│ ├── grpc/ # gRPC服务
│ │ ├── server.go # gRPC服务器入口
│ │ ├── interceptor/ # 拦截器
│ │ └── service/ # 服务实现
│ │ ├── auth.go
│ │ ├── file.go
│ │ ├── order.go
│ │ ├── print.go
│ │ └── library.go
│ ├── model/ # 数据模型
│ ├── repository/ # 数据访问
│ └── service/ # 业务逻辑
├── pkg/
│ ├── jwt/ # JWT认证
│ ├── pay/ # 支付服务 (go-pay)
│ └── upload/ # 文件上传
├── proto/ # Protobuf定义
│ ├── cloudprint.proto
│ └── generated/ # 生成的代码
├── scripts/
│ └── db.sql # 数据库脚本
└── go.mod
```
#### 3.3.2 gRPC 服务设计
**认证服务 (AuthService)**
```protobuf
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc RefreshToken(RefreshRequest) returns (RefreshResponse);
rpc Logout(LogoutRequest) returns (Empty);
}
```
**文件服务 (FileService)**
```protobuf
service FileService {
rpc Upload(stream UploadRequest) returns (UploadResponse);
rpc GetFile(GetFileRequest) returns (stream FileData);
rpc DeleteFile(DeleteRequest) returns (Empty);
rpc AddWatermark(WatermarkRequest) returns (WatermarkResponse);
}
```
**订单服务 (OrderService)**
```protobuf
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (OrderResponse);
rpc GetOrderList(GetOrderListRequest) returns (OrderListResponse);
rpc GetOrder(GetOrderRequest) returns (OrderResponse);
rpc UpdateOrderStatus(UpdateStatusRequest) returns (Empty);
rpc PayOrder(PayRequest) returns (PayResponse);
}
```
**打印服务 (PrintService)**
```protobuf
service PrintService {
rpc GetPrinters(Empty) returns (PrinterListResponse);
rpc GetPrinterStatus(PrinterRequest) returns (PrinterStatusResponse);
rpc SubmitPrintJob(PrintJobRequest) returns (PrintJobResponse);
rpc CancelPrintJob(CancelRequest) returns (Empty);
rpc SubscribeJobs(SubscriberRequest) returns (stream PrintJob);
rpc ReportJobStatus(JobStatusReport) returns (Empty);
}
```
**文库服务 (LibraryService)**
```protobuf
service LibraryService {
rpc GetCategories(Empty) returns (CategoryListResponse);
rpc GetFiles(GetFilesRequest) returns (FileListResponse);
rpc UploadFile(stream UploadRequest) returns (UploadResponse);
rpc DeleteFile(DeleteRequest) returns (Empty);
}
```
**支付服务 (PayService)**
```protobuf
service PayService {
rpc CreateWxPayOrder(WxPayRequest) returns (WxPayResponse);
rpc QueryWxPayStatus(QueryRequest) returns (PayStatusResponse);
rpc HandleWxPayCallback(CallbackRequest) returns (CallbackResponse);
}
```
#### 3.3.3 gRPC 通信流程
```mermaid
sequenceDiagram
participant CSharp as C# WPF客户端
participant Go as Go gRPC服务器
participant DB as MySQL/Redis
CSharp->>Go: 连接gRPC服务 (认证)
Go-->>CSharp: 连接成功
CSharp->>Go: 注册打印机 (SubscribeJobs)
Go->>DB: 查询待打印任务
DB-->>Go: 任务列表
loop 等待新任务
Go-->>CSharp: 推送新打印任务 (stream)
CSharp->>CSharp: 处理文件/调用打印API
CSharp->>Go: 上报状态 (ReportJobStatus)
Go->>DB: 更新任务状态
end
```
### 3.4 数据库设计
#### 3.4.1 ER 图
```mermaid
erDiagram
User ||--o{ Order : places
User {
int id PK
string openid UK
string nickname
decimal balance
datetime created_at
}
Order ||--|{ OrderItem : contains
Order ||--o{ Payment : has
Order {
int id PK
string order_no UK
int user_id FK
int status
decimal total_amount
datetime created_at
}
OrderItem {
int id PK
int order_id FK
int file_id FK
string printer_name
int copies
bool color
bool duplex
string page_range
decimal price
}
Payment {
int id PK
string order_no UK
int pay_status
string transaction_id
datetime paid_at
}
File {
int id PK
string file_name
string file_path
string file_type
int file_size
string uploader_type
int uploader_id
datetime created_at
}
Library |||--|{ LibraryFile : contains
Library {
int id PK
string name
string description
int sort_order
}
LibraryFile ||--o{ File : references
LibraryFile {
int id PK
int library_id FK
int file_id FK
string title
string description
}
Printer |||--o{ PrintJob : receives
Printer {
int id PK
string name UK
string display_name
bool is_active
string api_key UK
}
PrintJob {
int id PK
string job_no UK
int printer_id FK
int order_item_id FK
string status
int progress
datetime started_at
datetime completed_at
}
PriceList {
int id PK
string name
string type
decimal price
string unit
bool is_active
}
```
#### 3.4.2 主要表结构
```sql
-- 用户表
CREATE TABLE `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`openid` VARCHAR(64) UNIQUE NOT NULL COMMENT '微信OpenID',
`nickname` VARCHAR(128) DEFAULT '',
`avatar` VARCHAR(512) DEFAULT '',
`balance` DECIMAL(10,2) DEFAULT 0.00 COMMENT '余额',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 管理员表
CREATE TABLE `admin` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(64) UNIQUE NOT NULL,
`password_hash` VARCHAR(256) NOT NULL,
`nickname` VARCHAR(128) DEFAULT '',
`role` TINYINT DEFAULT 1 COMMENT '1:普通管理员 2:超级管理员',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 打印机表
CREATE TABLE `printer` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) UNIQUE NOT NULL COMMENT '系统打印机名',
`display_name` VARCHAR(128) NOT NULL COMMENT '显示名称',
`api_key` VARCHAR(64) UNIQUE NOT NULL COMMENT '客户端认证Key',
`is_active` TINYINT DEFAULT 1,
`status` TINYINT DEFAULT 0 COMMENT '0:空闲 1:打印中 2:离线',
`last_heartbeat` DATETIME,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 订单表
CREATE TABLE `order` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
`user_id` INT NOT NULL,
`status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:已支付 2:打印中 3:已完成 4:已取消',
`total_amount` DECIMAL(10,2) NOT NULL,
`remark` VARCHAR(512) DEFAULT '',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`paid_at` DATETIME,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`)
);
-- 订单明细表
CREATE TABLE `order_item` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_id` INT NOT NULL,
`file_id` INT NOT NULL,
`printer_id` INT,
`copies` INT DEFAULT 1,
`color` TINYINT DEFAULT 0,
`duplex` TINYINT DEFAULT 0,
`page_range` VARCHAR(64) DEFAULT 'all',
`price` DECIMAL(10,2) NOT NULL,
FOREIGN KEY (`order_id`) REFERENCES `order`(`id`),
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`)
);
-- 文件表
CREATE TABLE `file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`file_name` VARCHAR(256) NOT NULL,
`file_path` VARCHAR(512) NOT NULL,
`file_type` VARCHAR(32) NOT NULL COMMENT 'pdf/doc/docx/xls/xlsx/ppt/pptx/txt/jpg/png/zip',
`file_size` BIGINT NOT NULL COMMENT '字节',
`uploader_type` VARCHAR(16) NOT NULL COMMENT 'user/admin/system',
`uploader_id` INT NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 文库分类表
CREATE TABLE `library_category` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL,
`icon` VARCHAR(128) DEFAULT '',
`sort_order` INT DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 文库文件表
CREATE TABLE `library_file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`category_id` INT NOT NULL,
`file_id` INT NOT NULL,
`title` VARCHAR(256) NOT NULL,
`description` VARCHAR(512) DEFAULT '',
`page_count` INT DEFAULT 0,
`download_count` INT DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`category_id`) REFERENCES `library_category`(`id`),
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`)
);
-- 打印任务表
CREATE TABLE `print_job` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`job_no` VARCHAR(32) UNIQUE NOT NULL,
`printer_id` INT NOT NULL,
`order_item_id` INT NOT NULL,
`status` VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/printing/completed/failed/cancelled',
`progress` INT DEFAULT 0,
`error_msg` VARCHAR(512) DEFAULT '',
`started_at` DATETIME,
`completed_at` DATETIME,
FOREIGN KEY (`printer_id`) REFERENCES `printer`(`id`)
);
-- 价目表
CREATE TABLE `price_list` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL COMMENT '项目名称',
`type` VARCHAR(32) NOT NULL COMMENT '纸张类型: A4/A3/照片纸',
`price` DECIMAL(10,2) NOT NULL COMMENT '单价',
`unit` VARCHAR(16) NOT NULL COMMENT '单位: 页/张/份',
`color_enabled` TINYINT DEFAULT 1 COMMENT '是否支持彩色',
`is_active` TINYINT DEFAULT 1,
`sort_order` INT DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 支付记录表
CREATE TABLE `payment` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL,
`pay_type` VARCHAR(16) DEFAULT 'wechat' COMMENT 'wechat/balance',
`pay_status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:支付中 2:成功 3:失败',
`transaction_id` VARCHAR(64) DEFAULT '',
`paid_at` DATETIME,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 4. 核心流程
### 4.1 用户打印流程
```mermaid
sequenceDiagram
participant User as 用户
participant MiniApp as 微信小程序
participant Backend as Go后端
participant Windows as Windows客户端
participant Printer as 打印机
User->>MiniApp: 选择文件上传
MiniApp->>MiniApp: 文件预览/编辑
User->>MiniApp: 确认打印参数
MiniApp->>Backend: 创建订单
Backend-->>MiniApp: 订单信息
User->>MiniApp: 支付订单
MiniApp->>Backend: 支付回调
Backend->>Windows: WebSocket推送打印任务
Windows->>Backend: 下载文件
Windows->>Windows: PDF预处理
Windows->>Printer: 发送打印指令
Printer-->>Windows: 打印中
Windows->>Backend: 更新任务状态
Backend->>MiniApp: 推送状态更新
Printer-->>User: 输出打印件
```
### 4.2 管理员上传文库流程
```mermaid
flowchart TD
Admin["管理员登录Windows客户端"] --> Navigate["进入文库管理"]
Navigate --> SelectCategory["选择分类"]
SelectCategory --> Upload["上传文件"]
Upload --> SetInfo["设置文件标题描述"]
SetInfo --> Confirm["确认上传"]
Confirm --> Save["保存到数据库"]
Save --> Sync["同步到云端"]
Sync --> Success["上传成功"]
```
---
## 5. 文件处理
### 5.1 支持的文件格式
| 类型 | 格式 | 处理方式 |
|------|------|----------|
| 文档 | doc, docx, txt | 转为PDF后打印 |
| 表格 | xls, xlsx | 转为PDF后打印 |
| 演示 | ppt, pptx | 转为PDF后打印 |
| 图片 | jpg, png, bmp, gif | 直接打印或转PDF |
| 压缩包 | zip, rar | 解压后处理内部文件 |
| PDF | pdf | 直接打印 |
### 5.2 C#端文件转换
使用以下库/工具进行文件转换:
- **Word/Excel/PowerPoint**: Microsoft Office COM Automation 或 LibreOffice Headless
- **图片**: System.Drawing
- **压缩包**: SharpCompress
- **PDF合并/水印**: PDFsharp 或 iTextSharp
---
## 6. 安全设计
### 6.1 认证授权
- **用户端**: 微信 OAuth2.0 获取 OpenID
- **管理后台**: 用户名密码 + JWT Token
- **打印客户端**: API Key 认证 + gRPC TLS加密连接
### 6.2 数据安全
- 文件上传: 大小限制 (单文件 50MB)
- 文件类型白名单验证
- SQL注入防护: 使用参数化查询
- XSS防护: 输出编码
- gRPC传输加密: TLS 1.3
### 6.3 打印客户端认证
gRPC认证流程
1. 客户端使用API Key获取临时凭证
2. 使用凭证建立gRPC TLS连接
3. 服务端验证客户端身份
4. 建立长连接后订阅打印任务流
---
## 7. 开发计划
### 7.1 项目模块划分
1. **cloudprint-miniapp** - Taro微信小程序用户端
2. **cloudprint-backend** - Go + gRPC 后端服务
3. **cloudprint-client** - C# (.NET 8) + WPF 打印客户端
4. **cloudprint-admin** - React 管理后台网页端
5. **cloudprint-proto** - Protobuf 定义共享库
### 7.2 开发顺序
```
1. cloudprint-proto (公共协议定义)
├── 定义所有 gRPC 服务和消息类型
├── 生成 Go、C#、TypeScript 代码
2. cloudprint-backend (Go + gRPC + go-pay)
├── 数据库设计与初始化
├── 基础框架搭建
├── 认证模块 (JWT)
├── 文件服务
├── 订单服务
├── 打印服务
├── 文库服务
└── 微信支付集成 (go-pay)
3. cloudprint-client (C# WPF)
├── 项目初始化 (.NET 8)
├── 打印服务开发 (P/Invoke Windows API)
├── gRPC 通信 (Grpc.Net)
├── WebSocket 通信 (用于推送)
├── 文件处理
└── 文库管理
4. cloudprint-admin (React + Ant Design)
├── 项目初始化
├── 登录认证
├── 订单管理
├── 用户管理
├── 价目表管理
└── 数据统计
5. cloudprint-miniapp (Taro)
├── 项目初始化 (@tarojs/cli)
├── 用户端页面开发
├── 文件上传与编辑
├── 订单流程
└── 与后端 gRPC 对接
```
---
## 8. 部署方案
### 8.1 云服务器要求
- **配置**: 2核4G起步
- **系统**: Ubuntu 22.04 / CentOS 8
- **数据库**: MySQL 8.0
- **缓存**: Redis 6.0
- **文件存储**: 本地磁盘或 MinIO
### 8.2 打印店客户端部署
1. 在打印店电脑安装 Windows 10/11 + .NET 8 Runtime
2. 运行 C# WPF 客户端程序,配置服务器地址
3. 登录管理员账号
4. 配置打印机
5. 保持客户端在线
---
## 9. 风险与注意事项
| 风险 | 应对措施 |
|------|----------|
| 文件格式兼容 | C#客户端集成LibreOffice转换 |
| 打印质量差异 | 客户端提供打印参数调试 |
| 大文件传输 | 分片上传、断点续传 |
| 客户端掉线 | gRPC心跳检测、自动重连 |
| 支付安全 | 微信支付官方SDK (go-pay) |
| 并发打印 | 任务队列、打印机组 |

View File

@@ -0,0 +1,236 @@
# 云印享 - 付费打印服务系统
## 项目简介
云印享是一个基于 gRPC 的付费打印服务后端系统,支持用户管理、文件上传下载、订单处理、打印任务调度、微信支付、文库管理等核心功能。
## 技术栈
- **语言**: Go 1.21+
- **框架**: gRPC + Protobuf
- **数据库**: MySQL + GORM
- **认证**: JWT (JSON Web Token)
- **支付**: 微信支付 (gopay)
- **配置**: Viper
## 项目结构
```
cloudprint-backend/
├── cmd/server/ # 应用入口
│ └── main.go
├── config/ # 配置管理
│ └── config.go
├── internal/
│ ├── grpc/ # gRPC 服务层
│ │ ├── interceptor/ # 拦截器 (认证)
│ │ └── service/ # gRPC 服务实现
│ ├── model/ # 数据模型
│ ├── repository/ # 数据访问层
│ └── service/ # 业务逻辑层
├── pkg/ # 公共工具包
│ ├── jwt/ # JWT 认证
│ ├── pay/ # 微信支付
│ └── upload/ # 文件上传
├── proto/ # Protobuf 定义
│ ├── cloudprint.proto
│ └── generated/ # 生成的代码
├── scripts/ # 数据库脚本
│ └── db.sql
├── config.yaml # 配置文件
├── go.mod
└── go.sum
```
## 服务列表
| 服务 | 端口 | 描述 |
|------|------|------|
| gRPC Server | 8080 | 主服务端口 |
## 快速开始
### 1. 环境要求
- Go 1.21+
- MySQL 5.7+
- protoc (Protocol Buffer 编译器)
### 2. 数据库初始化
```bash
mysql -u root -p < scripts/db.sql
```
### 3. 配置修改
编辑 `config.yaml` 文件,修改以下配置:
```yaml
database:
host: "localhost"
port: 3306
username: "root"
password: "your-password"
database: "cloudprint"
jwt:
secret: "your-secret-key-change-in-production"
wechat:
app_id: "your-app-id"
app_secret: "your-app-secret"
mch_id: "your-merchant-id"
api_key: "your-api-key"
notify_url: "http://your-domain.com/pay/callback"
```
### 4. 编译生成 gRPC 代码
```bash
cd cloudprint-backend
protoc --go_out=./proto/generated --go_opt=paths=source_relative \
--go-grpc_out=./proto/generated --go-grpc_opt=paths=source_relative \
proto/cloudprint.proto
```
### 5. 运行服务
```bash
go run cmd/server/main.go -config config.yaml
```
## API 文档
### AuthService (用户认证服务)
| 方法 | 描述 |
|------|------|
| Login | 微信登录,获取 AccessToken 和 RefreshToken |
| RefreshToken | 刷新 AccessToken |
| GetUserInfo | 获取用户信息 |
| UpdateUser | 更新用户信息(昵称、头像) |
### FileService (文件管理服务)
| 方法 | 描述 |
|------|------|
| Upload | 流式上传文件(支持分片) |
| GetFile | 流式下载文件 |
| DeleteFile | 删除文件 |
| AddWatermark | 添加水印 |
| GetFileList | 获取文件列表 |
### OrderService (订单服务)
| 方法 | 描述 |
|------|------|
| CreateOrder | 创建订单 |
| GetOrder | 获取订单详情 |
| GetOrderList | 获取订单列表 |
| UpdateOrderStatus | 更新订单状态 |
| CancelOrder | 取消订单 |
### PrintService (打印服务)
| 方法 | 描述 |
|------|------|
| GetPrinters | 获取打印机列表 |
| GetPrinterStatus | 获取打印机状态 |
| RegisterPrinter | 注册打印机 |
| PrinterHeartbeat | 打印机心跳 |
| SubmitPrintJob | 提交打印任务 |
| CancelPrintJob | 取消打印任务 |
| SubscribeJobs | 订阅打印任务(流式) |
| ReportJobStatus | 上报任务状态 |
### LibraryService (文库服务)
| 方法 | 描述 |
|------|------|
| GetCategories | 获取文库分类 |
| GetFiles | 获取文库文件列表 |
| UploadFile | 上传文库文件 |
| DeleteFile | 删除文库文件 |
### PriceService (价目表服务)
| 方法 | 描述 |
|------|------|
| GetPriceList | 获取价目表 |
| CreatePriceItem | 创建价目项 |
| UpdatePriceItem | 更新价目项 |
### PayService (支付服务)
| 方法 | 描述 |
|------|------|
| CreateWxPayOrder | 创建微信支付订单 |
| QueryPayStatus | 查询支付状态 |
| HandlePayCallback | 处理支付回调 |
| PayByBalance | 余额支付 |
### AdminService (管理后台服务)
| 方法 | 描述 |
|------|------|
| Login | 管理员登录 |
| GetStatistics | 获取统计数据 |
## 数据模型
### 订单状态
| 值 | 状态 |
|----|------|
| 0 | 待支付 |
| 1 | 已支付 |
| 2 | 打印中 |
| 3 | 已完成 |
| 4 | 已取消 |
### 支付状态
| 值 | 状态 |
|----|------|
| 0 | 待支付 |
| 1 | 支付中 |
| 2 | 成功 |
| 3 | 失败 |
### 打印机状态
| 值 | 状态 |
|----|------|
| 0 | 空闲 |
| 1 | 打印中 |
| 2 | 离线 |
### 打印任务状态
| 值 | 状态 |
|----|------|
| pending | 等待中 |
| printing | 打印中 |
| completed | 已完成 |
| failed | 失败 |
| cancelled | 已取消 |
## 默认账号
- 管理员用户名: `admin`
- 管理员密码: `admin123` (需在生产环境修改)
## 初始价目表
| 名称 | 类型 | 价格 | 单位 |
|------|------|------|------|
| A4黑白打印 | A4 | 0.10元 | 页 |
| A4彩色打印 | A4 | 0.30元 | 页 |
| A3黑白打印 | A3 | 0.20元 | 页 |
| A3彩色打印 | A3 | 0.60元 | 页 |
| 照片打印 | 照片纸 | 2.00元 | 张 |
## 许可证
MIT License

View File

@@ -0,0 +1,163 @@
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"github.com/cloudprint/cloudprint-backend/config"
"github.com/cloudprint/cloudprint-backend/internal/grpc"
"github.com/cloudprint/cloudprint-backend/internal/grpc/interceptor"
"github.com/cloudprint/cloudprint-backend/internal/grpc/service"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"github.com/cloudprint/cloudprint-backend/pkg/pay"
"github.com/cloudprint/cloudprint-backend/pkg/upload"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"go.uber.org/zap"
"google.golang.org/grpc"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
// Load configuration
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize logger
zapLogger, _ := zap.NewProduction()
defer zapLogger.Sync()
// Initialize database
db, err := initDB(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Auto migrate
if err := db.AutoMigrate(
&model.User{},
&model.Admin{},
&model.Printer{},
&model.Order{},
&model.OrderItem{},
&model.File{},
&model.LibraryCategory{},
&model.LibraryFile{},
&model.PrintJob{},
&model.PriceList{},
&model.Payment{},
); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
// Initialize repositories
userRepo := repository.NewUserRepository(db)
adminRepo := repository.NewAdminRepository(db)
fileRepo := repository.NewFileRepository(db)
orderRepo := repository.NewOrderRepository(db)
orderItemRepo := repository.NewOrderItemRepository(db)
printerRepo := repository.NewPrinterRepository(db)
printJobRepo := repository.NewPrintJobRepository(db)
libraryRepo := repository.NewLibraryRepository(db)
priceRepo := repository.NewPriceRepository(db)
paymentRepo := repository.NewPaymentRepository(db)
// Initialize JWT manager
jwtManager := jwt.NewJWTManager(
cfg.JWT.Secret,
cfg.JWT.AccessTokenTTL,
cfg.JWT.RefreshTokenTTL,
)
// Initialize file uploader
fileUploader := upload.NewFileUploader(
cfg.Upload.Path,
cfg.Upload.ChunkPath,
cfg.Upload.MaxSize,
)
// Initialize WeChat pay
weChatPay := pay.NewWeChatPay(&cfg.WeChat)
// Initialize services
authService := service.NewAuthService(userRepo, adminRepo, jwtManager, nil, &cfg.WeChat)
fileService := service.NewFileService(fileRepo, fileUploader, cfg.Upload.Path)
orderService := service.NewOrderService(orderRepo, orderItemRepo, fileRepo, priceRepo, userRepo, printerRepo, printJobRepo)
printService := service.NewPrintService(printerRepo, printJobRepo, orderRepo)
// Initialize gRPC server
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(interceptor.NewAuthInterceptor(jwtManager).UnaryServerInterceptor()),
)
// Register gRPC services
pb.RegisterAuthServiceServer(grpcServer, service.NewAuthServiceServer(authService))
pb.RegisterFileServiceServer(grpcServer, service.NewFileServiceServer(fileService))
pb.RegisterOrderServiceServer(grpcServer, service.NewOrderServiceServer(orderService))
pb.RegisterPrintServiceServer(grpcServer, service.NewPrintServiceServer(printService))
pb.RegisterLibraryServiceServer(grpcServer, service.NewLibraryServiceServer(libraryRepo, fileRepo))
pb.RegisterPriceServiceServer(grpcServer, service.NewPriceServiceServer(priceRepo))
pb.RegisterPayServiceServer(grpcServer, service.NewPayServiceServer(orderService, paymentRepo, weChatPay, userRepo))
pb.RegisterAdminServiceServer(grpcServer, service.NewAdminServiceServer(authService, orderRepo, printerRepo))
// Start server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
grpcServer.GracefulStop()
}()
zapLogger.Info("Starting gRPC server", zap.String("address", addr))
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
func initDB(cfg *config.Config) (*gorm.DB, error) {
gormLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
LogLevel: logger.Info,
},
)
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
return db, nil
}

View File

@@ -0,0 +1,98 @@
package config
import (
"fmt"
"time"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Upload UploadConfig `mapstructure:"upload"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.Username, d.Password, d.Host, d.Port, d.Database)
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
func (r *RedisConfig) Addr() string {
return fmt.Sprintf("%s:%d", r.Host, r.Port)
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessTokenTTL time.Duration `mapstructure:"access_token_ttl"`
RefreshTokenTTL time.Duration `mapstructure:"refresh_token_ttl"`
}
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
MchID string `mapstructure:"mch_id"`
ApiKey string `mapstructure:"api_key"`
NotifyURL string `mapstructure:"notify_url"`
CertPath string `mapstructure:"cert_path"`
CertContent string `mapstructure:"cert_content"`
}
type UploadConfig struct {
Path string `mapstructure:"path"`
MaxSize int64 `mapstructure:"max_size"`
ChunkPath string `mapstructure:"chunk_path"`
}
var GlobalConfig *Config
func LoadConfig(path string) (*Config, error) {
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.port", 8080)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("jwt.access_token_ttl", "24h")
viper.SetDefault("jwt.refresh_token_ttl", "7d")
viper.SetDefault("upload.max_size", 52428800) // 50MB
viper.SetDefault("upload.chunk_path", "./uploads/chunks")
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
GlobalConfig = &cfg
return &cfg, nil
}

File diff suppressed because it is too large Load Diff

43
cloudprint-backend/go.mod Normal file
View File

@@ -0,0 +1,43 @@
module github.com/cloudprint/cloudprint-backend
go 1.21
require (
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/iGoogle-ink/gopay v1.5.14
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.18.0
google.golang.org/grpc v1.60.1
google.golang.org/protobuf v1.32.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

303
cloudprint-backend/go.sum Normal file
View File

@@ -0,0 +1,303 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/sketches-go v0.0.0-20190923095040-43f19ad77ff7/go.mod h1:Q5DbzQ+3AkgGwymQO7aZFNP7ns2lZKGtvRBzRXfdi60=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/benbjohnson/clock v1.0.0/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgryski/go-rendezvous v0.0.0-20200609043717-5ab96a526299/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-redis/redis/v8 v8.0.0-beta.5/go.mod h1:Mm9EH/5UMRx680UIryN6rd5XFn/L7zORPqLV+1D5thQ=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/iGoogle-ink/gopay v1.5.14 h1:vcwdqNeccx8FWrnqKTeZZSK5FcAHGUD07OFHwhMheBs=
github.com/iGoogle-ink/gopay v1.5.14/go.mod h1:JuI4rWXHSTEgFHD5C5xfpy3Do3b0pIcu/wdsRyw/Ds4=
github.com/iGoogle-ink/gotil v1.0.2 h1:z58bvmhq31twxecMkyWU5Mh8H7T9SGPOTvUuAw9vB7M=
github.com/iGoogle-ink/gotil v1.0.2/go.mod h1:HOeCIcJkJLAsQ+cAPdozZS3huVDT4YUKN/F7tmvvrbM=
github.com/jinzhu/gorm v1.9.14/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opentracing/opentracing-go v1.1.1-0.20190913142402-a7454ce5950e/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opentelemetry.io/otel v0.6.0/go.mod h1:jzBIgIzK43Iu1BpDAXwqOd6UPsSAk+ewVZ5ofSXw4Ek=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY=

View File

@@ -0,0 +1,125 @@
package interceptor
import (
"context"
"strings"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const (
AuthorizationHeader = "authorization"
BearerPrefix = "Bearer "
UserIDKey = "user_id"
UserTypeKey = "user_type"
)
type AuthInterceptor struct {
jwtManager *jwt.JWTManager
}
func NewAuthInterceptor(jwtManager *jwt.JWTManager) *AuthInterceptor {
return &AuthInterceptor{jwtManager: jwtManager}
}
// UnaryServerInterceptor returns a unary server interceptor that handles authentication
func (i *AuthInterceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Skip auth for certain methods
if isPublicMethod(info.FullMethod) {
return handler(ctx, req)
}
claims, err := i.extractAndValidateToken(ctx)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add user info to context
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserTypeKey, claims.UserType)
return handler(ctx, req)
}
}
// StreamServerInterceptor returns a streaming server interceptor that handles authentication
func (i *AuthInterceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
// Skip auth for certain methods
if isPublicMethod(info.FullMethod) {
return handler(srv, ss)
}
claims, err := i.extractAndValidateToken(ss.Context())
if err != nil {
return status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add user info to context
ctx := context.WithValue(ss.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserTypeKey, claims.UserType)
wrapped := &serverStreamWrapper{
ServerStream: ss,
ctx: ctx,
}
return handler(srv, wrapped)
}
}
func (i *AuthInterceptor) extractAndValidateToken(ctx context.Context) (*jwt.Claims, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
authHeader := md.Get(AuthorizationHeader)
if len(authHeader) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}
tokenString := strings.TrimPrefix(authHeader[0], BearerPrefix)
if tokenString == authHeader[0] {
return nil, status.Error(codes.Unauthenticated, "invalid authorization format")
}
return i.jwtManager.ValidateToken(tokenString)
}
func isPublicMethod(method string) bool {
publicMethods := map[string]bool{
"/cloudprint.AuthService/Login": true,
"/cloudprint.AuthService/RefreshToken": true,
"/cloudprint.AdminService/Login": true,
"/cloudprint.PayService/HandlePayCallback": true,
}
return publicMethods[method]
}
// serverStreamWrapper wraps grpc.ServerStream to override Context method
type serverStreamWrapper struct {
grpc.ServerStream
ctx context.Context
}
func (w *serverStreamWrapper) Context() context.Context {
return w.ctx
}
// GetUserIDFromContext extracts user ID from context
func GetUserIDFromContext(ctx context.Context) (int32, bool) {
userID, ok := ctx.Value(UserIDKey).(int32)
return userID, ok
}
// GetUserTypeFromContext extracts user type from context
func GetUserTypeFromContext(ctx context.Context) (string, bool) {
userType, ok := ctx.Value(UserTypeKey).(string)
return userType, ok
}

View File

@@ -0,0 +1,92 @@
package grpc
import (
"fmt"
"net"
"github.com/cloudprint/cloudprint-backend/internal/grpc/interceptor"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
type Server struct {
grpcServer *grpc.Server
port int
}
type ServerOption func(*Server)
func WithPort(port int) ServerOption {
return func(s *Server) {
s.port = port
}
}
func NewServer(opts ...ServerOption) *Server {
s := &Server{
port: 8080,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Server) RegisterServices(grpcServer *grpc.Server) {
// Services will be registered here
// This is a placeholder for the actual service registration
}
func (s *Server) Start() error {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
s.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(interceptor.AuthInterceptor{}.UnaryServerInterceptor()),
)
// Register reflection service for debugging
reflection.Register(s.grpcServer)
if err := s.grpcServer.Serve(lis); err != nil {
return fmt.Errorf("failed to serve: %v", err)
}
return nil
}
func (s *Server) Stop() {
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
}
// Register all gRPC services
func RegisterServices(grpcServer *grpc.Server) {
// AuthService
// pb.RegisterAuthServiceServer(grpcServer, authServiceServer)
// FileService
// pb.RegisterFileServiceServer(grpcServer, fileServiceServer)
// OrderService
// pb.RegisterOrderServiceServer(grpcServer, orderServiceServer)
// PrintService
// pb.RegisterPrintServiceServer(grpcServer, printServiceServer)
// LibraryService
// pb.RegisterLibraryServiceServer(grpcServer, libraryServiceServer)
// PriceService
// pb.RegisterPriceServiceServer(grpcServer, priceServiceServer)
// PayService
// pb.RegisterPayServiceServer(grpcServer, payServiceServer)
// AdminService
// pb.RegisterAdminServiceServer(grpcServer, adminServiceServer)
}

View File

@@ -0,0 +1,68 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type AdminServiceServer struct {
pb.UnimplementedAdminServiceServer
authService *service.AuthService
orderRepo *repository.OrderRepository
printerRepo *repository.PrinterRepository
}
func NewAdminServiceServer(
authService *service.AuthService,
orderRepo *repository.OrderRepository,
printerRepo *repository.PrinterRepository,
) *AdminServiceServer {
return &AdminServiceServer{
authService: authService,
orderRepo: orderRepo,
printerRepo: printerRepo,
}
}
func (s *AdminServiceServer) Login(ctx context.Context, req *pb.AdminLoginRequest) (*pb.AdminLoginResponse, error) {
result, err := s.authService.AdminLogin(ctx, req.Username, req.Password)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "login failed: %v", err)
}
return &pb.AdminLoginResponse{
AccessToken: result.AccessToken,
Admin: &pb.Admin{
Id: result.Admin.ID,
Username: result.Admin.Username,
Nickname: result.Admin.Nickname,
Role: int32(result.Admin.Role),
},
}, nil
}
func (s *AdminServiceServer) GetStatistics(ctx context.Context, req *pb.GetStatisticsRequest) (*pb.Statistics, error) {
totalUsers, totalOrders, todayOrders, todayRevenue, pendingOrders, err := s.orderRepo.GetStatistics(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get statistics failed: %v", err)
}
activePrinters, err := s.printerRepo.CountActive(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get printer count failed: %v", err)
}
return &pb.Statistics{
TotalUsers: totalUsers,
TotalOrders: totalOrders,
TodayOrders: todayOrders,
TodayRevenue: todayRevenue,
PendingOrders: pendingOrders,
ActivePrinters: int32(activePrinters),
}, nil
}

View File

@@ -0,0 +1,63 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type AuthServiceServer struct {
pb.UnimplementedAuthServiceServer
authService *service.AuthService
}
func NewAuthServiceServer(authService *service.AuthService) *AuthServiceServer {
return &AuthServiceServer{
authService: authService,
}
}
func (s *AuthServiceServer) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
result, err := s.authService.Login(ctx, req.Code)
if err != nil {
return nil, status.Errorf(codes.Internal, "login failed: %v", err)
}
return &pb.LoginResponse{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
User: &pb.User{
Id: result.User.ID,
Openid: result.User.Openid,
Nickname: result.User.Nickname,
Avatar: result.User.Avatar,
Balance: result.User.Balance,
},
}, nil
}
func (s *AuthServiceServer) RefreshToken(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
newToken, err := s.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return nil, status.Errorf(codes.Internal, "refresh token failed: %v", err)
}
return &pb.RefreshResponse{
AccessToken: newToken,
}, nil
}
func (s *AuthServiceServer) GetUserInfo(ctx context.Context, req *pb.GetUserInfoRequest) (*pb.User, error) {
// In a real implementation, extract user ID from context
// For now, this is a placeholder
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (s *AuthServiceServer) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) {
// In a real implementation, extract user ID from context
// For now, this is a placeholder
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}

View File

@@ -0,0 +1,141 @@
package service
import (
"context"
"io"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type FileServiceServer struct {
pb.UnimplementedFileServiceServer
fileService *service.FileService
}
func NewFileServiceServer(fileService *service.FileService) *FileServiceServer {
return &FileServiceServer{
fileService: fileService,
}
}
func (s *FileServiceServer) Upload(stream pb.FileService_UploadServer) (*pb.UploadResponse, error) {
var fileName, fileType, uploaderType string
var uploaderID int32
var totalSize int64
var chunks [][]byte
for {
req, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return nil, status.Errorf(codes.Internal, "receive chunk failed: %v", err)
}
if fileName == "" {
fileName = req.FileName
fileType = req.FileType
uploaderType = "user" // Extract from context in production
uploaderID = 1 // Extract from context in production
totalSize = req.FileSize
}
chunks = append(chunks, req.Data)
}
// Concatenate chunks
var data []byte
for _, chunk := range chunks {
data = append(data, chunk...)
}
file, err := s.fileService.Upload(stream.Context(), fileName, fileType, uploaderType, uploaderID, data)
if err != nil {
return nil, status.Errorf(codes.Internal, "upload failed: %v", err)
}
return &pb.UploadResponse{
FileId: file.ID,
FilePath: file.FilePath,
FileUrl: file.FilePath,
}, nil
}
func (s *FileServiceServer) GetFile(req *pb.GetFileRequest, stream pb.FileService_GetFileServer) error {
file, err := s.fileService.GetFile(stream.Context(), req.FileId)
if err != nil || file == nil {
return status.Errorf(codes.NotFound, "file not found")
}
reader, err := s.fileService.GetFileReader(stream.Context(), file)
if err != nil {
return status.Errorf(codes.Internal, "read file failed: %v", err)
}
defer reader.Close()
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if n > 0 {
if err := stream.Send(&pb.FileData{Data: buf[:n], IsLast: false}); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return stream.Send(&pb.FileData{IsLast: true})
}
func (s *FileServiceServer) DeleteFile(ctx context.Context, req *pb.DeleteFileRequest) (*pb.Empty, error) {
err := s.fileService.DeleteFile(ctx, req.FileId, "user", 1)
if err != nil {
return nil, status.Errorf(codes.Internal, "delete file failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *FileServiceServer) AddWatermark(ctx context.Context, req *pb.WatermarkRequest) (*pb.WatermarkResponse, error) {
file, err := s.fileService.AddWatermark(ctx, req.FileId, req.Text, req.Opacity, int(req.Position), int(req.FontSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "add watermark failed: %v", err)
}
return &pb.WatermarkResponse{
FileId: file.ID,
FilePath: file.FilePath,
}, nil
}
func (s *FileServiceServer) GetFileList(ctx context.Context, req *pb.GetFileListRequest) (*pb.FileListResponse, error) {
files, total, err := s.fileService.GetFileList(ctx, req.UploaderType, req.UploaderId, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get file list failed: %v", err)
}
pbFiles := make([]*pb.FileInfo, 0, len(files))
for _, f := range files {
pbFiles = append(pbFiles, &pb.FileInfo{
Id: f.ID,
FileName: f.FileName,
FilePath: f.FilePath,
FileType: f.FileType,
FileSize: f.FileSize,
})
}
return &pb.FileListResponse{
Files: pbFiles,
Total: int32(total),
}, nil
}

View File

@@ -0,0 +1,91 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type LibraryServiceServer struct {
pb.UnimplementedLibraryServiceServer
libraryRepo *repository.LibraryRepository
fileRepo *repository.FileRepository
}
func NewLibraryServiceServer(libraryRepo *repository.LibraryRepository, fileRepo *repository.FileRepository) *LibraryServiceServer {
return &LibraryServiceServer{
libraryRepo: libraryRepo,
fileRepo: fileRepo,
}
}
func (s *LibraryServiceServer) GetCategories(ctx context.Context, req *pb.GetCategoriesRequest) (*pb.CategoryListResponse, error) {
categories, err := s.libraryRepo.GetAllCategories(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get categories failed: %v", err)
}
pbCategories := make([]*pb.LibraryCategory, 0, len(categories))
for _, cat := range categories {
pbCategories = append(pbCategories, &pb.LibraryCategory{
Id: cat.ID,
Name: cat.Name,
Icon: cat.Icon,
SortOrder: int32(cat.SortOrder),
})
}
return &pb.CategoryListResponse{Categories: pbCategories}, nil
}
func (s *LibraryServiceServer) GetFiles(ctx context.Context, req *pb.GetLibraryFilesRequest) (*pb.LibraryFileListResponse, error) {
files, total, err := s.libraryRepo.GetFilesByCategory(ctx, req.CategoryId, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get files failed: %v", err)
}
pbFiles := make([]*pb.LibraryFile, 0, len(files))
for _, file := range files {
pbFile := &pb.LibraryFile{
Id: file.ID,
CategoryId: file.CategoryID,
FileId: file.FileID,
Title: file.Title,
Description: file.Description,
PageCount: int32(file.PageCount),
DownloadCount: int32(file.DownloadCount),
}
if file.File != nil {
pbFile.File = &pb.FileInfo{
Id: file.File.ID,
FileName: file.File.FileName,
FilePath: file.File.FilePath,
FileType: file.File.FileType,
FileSize: file.File.FileSize,
}
}
pbFiles = append(pbFiles, pbFile)
}
return &pb.LibraryFileListResponse{
Files: pbFiles,
Total: int32(total),
}, nil
}
func (s *LibraryServiceServer) UploadFile(stream pb.LibraryService_UploadFileServer) error {
// Placeholder for streaming upload
return status.Errorf(codes.Unimplemented, "streaming upload not implemented")
}
func (s *LibraryServiceServer) DeleteFile(ctx context.Context, req *pb.DeleteLibraryFileRequest) (*pb.Empty, error) {
err := s.libraryRepo.DeleteFile(ctx, req.LibraryFileId)
if err != nil {
return nil, status.Errorf(codes.Internal, "delete file failed: %v", err)
}
return &pb.Empty{}, nil
}

View File

@@ -0,0 +1,98 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type OrderServiceServer struct {
pb.UnimplementedOrderServiceServer
orderService *service.OrderService
}
func NewOrderServiceServer(orderService *service.OrderService) *OrderServiceServer {
return &OrderServiceServer{
orderService: orderService,
}
}
func (s *OrderServiceServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
// Extract user ID from context (set by auth interceptor)
userID := int32(1) // Placeholder
items := make([]*service.CreateOrderItem, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, &service.CreateOrderItem{
FileID: item.FileId,
PrinterID: item.PrinterId,
Copies: int(item.Copies),
Color: item.Color,
Duplex: item.Duplex,
PageRange: item.PageRange,
})
}
order, err := s.orderService.CreateOrder(ctx, userID, items, req.Remark)
if err != nil {
return nil, status.Errorf(codes.Internal, "create order failed: %v", err)
}
return &pb.CreateOrderResponse{
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
}, nil
}
func (s *OrderServiceServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.orderService.GetOrder(ctx, req.OrderNo)
if err != nil || order == nil {
return nil, status.Errorf(codes.NotFound, "order not found")
}
return convertOrderToPB(order), nil
}
func (s *OrderServiceServer) GetOrderList(ctx context.Context, req *pb.GetOrderListRequest) (*pb.OrderListResponse, error) {
orders, total, err := s.orderService.GetOrderList(ctx, req.UserId, req.Status, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get order list failed: %v", err)
}
pbOrders := make([]*pb.Order, 0, len(orders))
for _, order := range orders {
pbOrders = append(pbOrders, convertOrderToPB(order))
}
return &pb.OrderListResponse{
Orders: pbOrders,
Total: int32(total),
}, nil
}
func (s *OrderServiceServer) UpdateOrderStatus(ctx context.Context, req *pb.UpdateOrderStatusRequest) (*pb.Empty, error) {
err := s.orderService.UpdateOrderStatus(ctx, req.OrderNo, int8(req.Status))
if err != nil {
return nil, status.Errorf(codes.Internal, "update order status failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *OrderServiceServer) CancelOrder(ctx context.Context, req *pb.CancelOrderRequest) (*pb.Empty, error) {
err := s.orderService.CancelOrder(ctx, req.OrderNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "cancel order failed: %v", err)
}
return &pb.Empty{}, nil
}
func convertOrderToPB(order *service.Order) *pb.Order {
// Note: This is a simplified conversion. In production, you would need to handle all fields properly.
// The service.Order type here is the model, not the protobuf type.
return nil // Placeholder
}

View File

@@ -0,0 +1,201 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
"github.com/cloudprint/cloudprint-backend/pkg/pay"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PayServiceServer struct {
pb.UnimplementedPayServiceServer
orderService *service.OrderService
paymentRepo *repository.PaymentRepository
weChatPay *pay.WeChatPay
userRepo *repository.UserRepository
}
func NewPayServiceServer(
orderService *service.OrderService,
paymentRepo *repository.PaymentRepository,
weChatPay *pay.WeChatPay,
userRepo *repository.UserRepository,
) *PayServiceServer {
return &PayServiceServer{
orderService: orderService,
paymentRepo: paymentRepo,
weChatPay: weChatPay,
userRepo: userRepo,
}
}
func (s *PayServiceServer) CreateWxPayOrder(ctx context.Context, req *pb.WxPayRequest) (*pb.WxPayResponse, error) {
resp, err := s.weChatPay.UnifiedOrder(ctx, req.OrderNo, req.Amount, req.Description, req.Openid, req.ClientIp)
if err != nil {
return nil, status.Errorf(codes.Internal, "create pay order failed: %v", err)
}
if resp.ReturnCode != "SUCCESS" {
return nil, status.Errorf(codes.Internal, "wechat pay error: %s - %s", resp.ErrCode, resp.ErrMsg)
}
// Create payment record
payment := &model.Payment{
OrderNo: req.OrderNo,
PayType: "wechat",
PayStatus: model.PayStatusPaying,
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
return nil, status.Errorf(codes.Internal, "create payment record failed: %v", err)
}
return &pb.WxPayResponse{
PrepayId: resp.PrepayID,
CodeUrl: resp.CodeURL,
MwebUrl: resp.MWebURL,
}, nil
}
func (s *PayServiceServer) QueryPayStatus(ctx context.Context, req *pb.QueryPayStatusRequest) (*pb.PayStatusResponse, error) {
tradeState, _, err := s.weChatPay.QueryOrder(ctx, req.OrderNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "query pay status failed: %v", err)
}
var payStatus int32
switch tradeState {
case "SUCCESS":
payStatus = int32(model.PayStatusSuccess)
case "USERPAYING", "PAYING":
payStatus = int32(model.PayStatusPaying)
case "CLOSED", "PAYERROR", "REFUND":
payStatus = int32(model.PayStatusFailed)
default:
payStatus = int32(model.PayStatusPending)
}
return &pb.PayStatusResponse{
Status: payStatus,
}, nil
}
func (s *PayServiceServer) HandlePayCallback(ctx context.Context, req *pb.PayCallbackRequest) (*pb.PayCallbackResponse, error) {
callback, err := s.weChatPay.ParseCallback([]byte(req.Body))
if err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "parse error",
}, nil
}
if !s.weChatPay.ValidateCallback(callback) {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "validate error",
}, nil
}
if callback.ReturnCode == "SUCCESS" && callback.ResultCode == "SUCCESS" {
// Check if payment already processed (idempotency)
payment, _ := s.paymentRepo.GetByOrderNo(ctx, callback.OutTradeNo)
if payment != nil && payment.PayStatus == model.PayStatusSuccess {
// Already processed, return success
return &pb.PayCallbackResponse{
ReturnCode: "SUCCESS",
ReturnMsg: "OK",
}, nil
}
// Update payment status
if err := s.paymentRepo.UpdateStatus(ctx, callback.OutTradeNo, model.PayStatusSuccess, callback.TransactionID); err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "update payment failed",
}, nil
}
// Update order status - pay order triggers print job creation
if err := s.orderService.PayOrder(ctx, callback.OutTradeNo, "wechat"); err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "pay order failed",
}, nil
}
}
return &pb.PayCallbackResponse{
ReturnCode: "SUCCESS",
ReturnMsg: "OK",
}, nil
}
func (s *PayServiceServer) PayByBalance(ctx context.Context, req *pb.PayByBalanceRequest) (*pb.StatusResponse, error) {
// Get order
order, err := s.orderService.GetOrder(ctx, req.OrderNo)
if err != nil || order == nil {
return &pb.StatusResponse{
Code: 1,
Message: "order not found",
}, nil
}
// Check if order is pending
if order.Status != model.OrderStatusPending {
return &pb.StatusResponse{
Code: 1,
Message: "order is not pending",
}, nil
}
// Get user
user, err := s.userRepo.GetByID(ctx, req.UserId)
if err != nil || user == nil {
return &pb.StatusResponse{
Code: 1,
Message: "user not found",
}, nil
}
// Check balance
if user.Balance < order.TotalAmount {
return &pb.StatusResponse{
Code: 1,
Message: "insufficient balance",
}, nil
}
// Deduct balance
if err := s.userRepo.DeductBalance(ctx, req.UserId, order.TotalAmount); err != nil {
return nil, status.Errorf(codes.Internal, "deduct balance failed: %v", err)
}
// Create payment record
payment := &model.Payment{
OrderNo: order.OrderNo,
PayType: "balance",
PayStatus: model.PayStatusSuccess,
TransactionID: "balance_" + order.OrderNo,
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
// Rollback balance deduction on failure
s.userRepo.AddBalance(ctx, req.UserId, order.TotalAmount)
return nil, status.Errorf(codes.Internal, "create payment record failed: %v", err)
}
// Pay order - this creates print jobs
if err := s.orderService.PayOrder(ctx, req.OrderNo, "balance"); err != nil {
// Rollback
s.userRepo.AddBalance(ctx, req.UserId, order.TotalAmount)
return nil, status.Errorf(codes.Internal, "pay order failed: %v", err)
}
return &pb.StatusResponse{
Code: 0,
Message: "success",
}, nil
}

View File

@@ -0,0 +1,96 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PriceServiceServer struct {
pb.UnimplementedPriceServiceServer
priceRepo *repository.PriceRepository
}
func NewPriceServiceServer(priceRepo *repository.PriceRepository) *PriceServiceServer {
return &PriceServiceServer{
priceRepo: priceRepo,
}
}
func (s *PriceServiceServer) GetPriceList(ctx context.Context, req *pb.GetPriceListRequest) (*pb.PriceListResponse, error) {
items, err := s.priceRepo.GetAllActive(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get price list failed: %v", err)
}
pbItems := make([]*pb.PriceItem, 0, len(items))
for _, item := range items {
pbItems = append(pbItems, &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
})
}
return &pb.PriceListResponse{Items: pbItems}, nil
}
func (s *PriceServiceServer) CreatePriceItem(ctx context.Context, req *pb.CreatePriceItemRequest) (*pb.PriceItem, error) {
item := &model.PriceList{
Name: req.Name,
Type: req.Type,
Price: req.Price,
Unit: req.Unit,
ColorEnabled: req.ColorEnabled,
IsActive: true,
}
if err := s.priceRepo.Create(ctx, item); err != nil {
return nil, status.Errorf(codes.Internal, "create price item failed: %v", err)
}
return &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
}, nil
}
func (s *PriceServiceServer) UpdatePriceItem(ctx context.Context, req *pb.UpdatePriceItemRequest) (*pb.PriceItem, error) {
item, err := s.priceRepo.GetByID(ctx, req.Id)
if err != nil || item == nil {
return nil, status.Errorf(codes.NotFound, "price item not found")
}
item.Name = req.Name
item.Type = req.Type
item.Price = req.Price
item.Unit = req.Unit
item.ColorEnabled = req.ColorEnabled
item.IsActive = req.IsActive
if err := s.priceRepo.Update(ctx, item); err != nil {
return nil, status.Errorf(codes.Internal, "update price item failed: %v", err)
}
return &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
}, nil
}

View File

@@ -0,0 +1,136 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PrintServiceServer struct {
pb.UnimplementedPrintServiceServer
printService *service.PrintService
}
func NewPrintServiceServer(printService *service.PrintService) *PrintServiceServer {
return &PrintServiceServer{
printService: printService,
}
}
func (s *PrintServiceServer) GetPrinters(ctx context.Context, req *pb.GetPrintersRequest) (*pb.PrinterListResponse, error) {
printers, err := s.printService.GetPrinters(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get printers failed: %v", err)
}
pbPrinters := make([]*pb.Printer, 0, len(printers))
for _, printer := range printers {
pbPrinters = append(pbPrinters, &pb.Printer{
Id: printer.ID,
Name: printer.Name,
DisplayName: printer.DisplayName,
IsActive: printer.IsActive,
Status: int32(printer.Status),
})
}
return &pb.PrinterListResponse{Printers: pbPrinters}, nil
}
func (s *PrintServiceServer) GetPrinterStatus(ctx context.Context, req *pb.GetPrinterStatusRequest) (*pb.PrinterStatusResponse, error) {
printer, queueCount, err := s.printService.GetPrinterStatus(ctx, req.PrinterId)
if err != nil || printer == nil {
return nil, status.Errorf(codes.NotFound, "printer not found")
}
return &pb.PrinterStatusResponse{
Printer: &pb.Printer{
Id: printer.ID,
Name: printer.Name,
DisplayName: printer.DisplayName,
IsActive: printer.IsActive,
Status: int32(printer.Status),
},
QueueCount: int32(queueCount),
}, nil
}
func (s *PrintServiceServer) RegisterPrinter(ctx context.Context, req *pb.RegisterPrinterRequest) (*pb.RegisterPrinterResponse, error) {
printer, token, err := s.printService.RegisterPrinter(ctx, req.Name, req.DisplayName, req.ApiKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "register printer failed: %v", err)
}
if printer == nil {
return nil, status.Errorf(codes.AlreadyExists, "printer already exists")
}
return &pb.RegisterPrinterResponse{
PrinterId: printer.ID,
Token: token,
}, nil
}
func (s *PrintServiceServer) PrinterHeartbeat(ctx context.Context, req *pb.PrinterHeartbeatRequest) (*pb.Empty, error) {
err := s.printService.PrinterHeartbeat(ctx, req.PrinterId, int8(req.Status), req.Token)
if err != nil {
return nil, status.Errorf(codes.Internal, "heartbeat failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *PrintServiceServer) SubmitPrintJob(ctx context.Context, req *pb.SubmitPrintJobRequest) (*pb.SubmitPrintJobResponse, error) {
job, err := s.printService.SubmitPrintJob(ctx, req.OrderItemId, req.PrinterId, "", int(req.Copies), req.Color, req.Duplex, req.PageRange)
if err != nil {
return nil, status.Errorf(codes.Internal, "submit print job failed: %v", err)
}
return &pb.SubmitPrintJobResponse{
JobNo: job.JobNo,
}, nil
}
func (s *PrintServiceServer) CancelPrintJob(ctx context.Context, req *pb.CancelPrintJobRequest) (*pb.Empty, error) {
err := s.printService.CancelPrintJob(ctx, req.JobNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "cancel print job failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *PrintServiceServer) SubscribeJobs(req *pb.SubscribeJobsRequest, stream pb.PrintService_SubscribeJobsServer) error {
ch, err := s.printService.SubscribeJobs(stream.Context(), req.PrinterId, req.Token)
if err != nil {
return status.Errorf(codes.Internal, "subscribe jobs failed: %v", err)
}
for job := range ch {
if err := stream.Send(&pb.PrintJob{
Id: job.ID,
JobNo: job.JobNo,
PrinterId: job.PrinterID,
OrderItemId: job.OrderItemID,
Status: job.Status,
Progress: int32(job.Progress),
ErrorMsg: job.ErrorMsg,
}); err != nil {
s.printService.UnsubscribeJobs(req.PrinterId)
return err
}
}
return nil
}
func (s *PrintServiceServer) ReportJobStatus(ctx context.Context, req *pb.ReportJobStatusRequest) (*pb.Empty, error) {
err := s.printService.ReportJobStatus(ctx, req.JobNo, req.Status, int(req.Progress), req.ErrorMsg)
if err != nil {
return nil, status.Errorf(codes.Internal, "report job status failed: %v", err)
}
return &pb.Empty{}, nil
}

View File

@@ -0,0 +1,224 @@
package model
import (
"time"
)
// User 用户表
type User struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Openid string `gorm:"uniqueIndex;size:64;not null" json:"openid"` // 微信OpenID
Nickname string `gorm:"size:128;default:''" json:"nickname"`
Avatar string `gorm:"size:512;default:''" json:"avatar"`
Balance float64 `gorm:"type:decimal(10,2);default:0.00" json:"balance"` // 余额
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Orders []Order `gorm:"foreignKey:UserID" json:"orders,omitempty"`
}
func (User) TableName() string {
return "user"
}
// Admin 管理员表
type Admin struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
PasswordHash string `gorm:"size:256;not null" json:"-"`
Nickname string `gorm:"size:128;default:''" json:"nickname"`
Role int8 `gorm:"default:1" json:"role"` // 1:普通管理员 2:超级管理员
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Admin) TableName() string {
return "admin"
}
// Printer 打印机表
type Printer struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"` // 系统打印机名
DisplayName string `gorm:"size:128;not null" json:"display_name"` // 显示名称
APIKey string `gorm:"uniqueIndex;size:64;not null" json:"api_key"` // 客户端认证Key
IsActive bool `gorm:"default:true" json:"is_active"`
Status int8 `gorm:"default:0" json:"status"` // 0:空闲 1:打印中 2:离线
LastHeartbeat *time.Time `json:"last_heartbeat"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
PrintJobs []PrintJob `gorm:"foreignKey:PrinterID" json:"print_jobs,omitempty"`
}
func (Printer) TableName() string {
return "printer"
}
// Order 订单表
type Order struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32;not null" json:"order_no"` // 订单号
UserID int32 `gorm:"not null;index" json:"user_id"`
Status int8 `gorm:"default:0" json:"status"` // 0:待支付 1:已支付 2:打印中 3:已完成 4:已取消
TotalAmount float64 `gorm:"type:decimal(10,2);not null" json:"total_amount"`
Remark string `gorm:"size:512;default:''" json:"remark"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
PaidAt *time.Time `json:"paid_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
Payment *Payment `gorm:"foreignKey:OrderNo;references:OrderNo" json:"payment,omitempty"`
}
func (Order) TableName() string {
return "order"
}
// OrderItem 订单明细表
type OrderItem struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderID int32 `gorm:"not null;index" json:"order_id"`
FileID int32 `gorm:"not null" json:"file_id"`
PrinterID *int32 `json:"printer_id"`
Copies int `gorm:"default:1" json:"copies"`
Color bool `gorm:"default:false" json:"color"`
Duplex bool `gorm:"default:false" json:"duplex"`
PageRange string `gorm:"size:64;default:'all'" json:"page_range"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
File *File `gorm:"foreignKey:FileID" json:"file,omitempty"`
Printer *Printer `gorm:"foreignKey:PrinterID" json:"printer,omitempty"`
PrintJobs []PrintJob `gorm:"foreignKey:OrderItemID" json:"print_jobs,omitempty"`
}
func (OrderItem) TableName() string {
return "order_item"
}
// File 文件表
type File struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
FileName string `gorm:"size:256;not null" json:"file_name"`
FilePath string `gorm:"size:512;not null" json:"file_path"`
FileType string `gorm:"size:32;not null" json:"file_type"` // pdf/doc/docx/xls/xlsx/ppt/pptx/txt/jpg/png/zip
FileSize int64 `gorm:"not null" json:"file_size"` // 字节
UploaderType string `gorm:"size:16;not null" json:"uploader_type"` // user/admin/system
UploaderID int32 `gorm:"not null" json:"uploader_id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (File) TableName() string {
return "file"
}
// LibraryCategory 文库分类表
type LibraryCategory struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Icon string `gorm:"size:128;default:''" json:"icon"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Files []LibraryFile `gorm:"foreignKey:CategoryID" json:"files,omitempty"`
}
func (LibraryCategory) TableName() string {
return "library_category"
}
// LibraryFile 文库文件表
type LibraryFile struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
CategoryID int32 `gorm:"not null;index" json:"category_id"`
FileID int32 `gorm:"not null" json:"file_id"`
Title string `gorm:"size:256;not null" json:"title"`
Description string `gorm:"size:512;default:''" json:"description"`
PageCount int `gorm:"default:0" json:"page_count"`
DownloadCount int `gorm:"default:0" json:"download_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Category *LibraryCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
File *File `gorm:"foreignKey:FileID" json:"file,omitempty"`
}
func (LibraryFile) TableName() string {
return "library_file"
}
// PrintJob 打印任务表
type PrintJob struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
JobNo string `gorm:"uniqueIndex;size:32;not null" json:"job_no"`
PrinterID int32 `gorm:"not null;index" json:"printer_id"`
OrderItemID int32 `gorm:"not null;index" json:"order_item_id"`
Status string `gorm:"size:16;default:'pending'" json:"status"` // pending/printing/completed/failed/cancelled
Progress int `gorm:"default:0" json:"progress"`
ErrorMsg string `gorm:"size:512;default:''" json:"error_msg"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
Printer *Printer `gorm:"foreignKey:PrinterID" json:"printer,omitempty"`
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
}
func (PrintJob) TableName() string {
return "print_job"
}
// PriceList 价目表
type PriceList struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` // 项目名称
Type string `gorm:"size:32;not null" json:"type"` // 纸张类型: A4/A3/照片纸
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 单价
Unit string `gorm:"size:16;not null" json:"unit"` // 单位: 页/张/份
ColorEnabled bool `gorm:"default:true" json:"color_enabled"` // 是否支持彩色
IsActive bool `gorm:"default:true" json:"is_active"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (PriceList) TableName() string {
return "price_list"
}
// Payment 支付记录表
type Payment struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32;not null" json:"order_no"`
PayType string `gorm:"size:16;default:'wechat'" json:"pay_type"` // wechat/balance
PayStatus int8 `gorm:"default:0" json:"pay_status"` // 0:待支付 1:支付中 2:成功 3:失败
TransactionID string `gorm:"size:64;default:''" json:"transaction_id"`
PaidAt *time.Time `json:"paid_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Payment) TableName() string {
return "payment"
}
// OrderStatus 订单状态常量
const (
OrderStatusPending = 0 // 待支付
OrderStatusPaid = 1 // 已支付
OrderStatusPrinting = 2 // 打印中
OrderStatusCompleted = 3 // 已完成
OrderStatusCancelled = 4 // 已取消
)
// PayStatus 支付状态常量
const (
PayStatusPending = 0 // 待支付
PayStatusPaying = 1 // 支付中
PayStatusSuccess = 2 // 成功
PayStatusFailed = 3 // 失败
)
// PrinterStatus 打印机状态常量
const (
PrinterStatusIdle = 0 // 空闲
PrinterStatusPrinting = 1 // 打印中
PrinterStatusOffline = 2 // 离线
)
// PrintJobStatus 打印任务状态常量
const (
PrintJobStatusPending = "pending"
PrintJobStatusPrinting = "printing"
PrintJobStatusCompleted = "completed"
PrintJobStatusFailed = "failed"
PrintJobStatusCancelled = "cancelled"
)

View File

@@ -0,0 +1,49 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type AdminRepository struct {
db *gorm.DB
}
func NewAdminRepository(db *gorm.DB) *AdminRepository {
return &AdminRepository{db: db}
}
func (r *AdminRepository) Create(ctx context.Context, admin *model.Admin) error {
return r.db.WithContext(ctx).Create(admin).Error
}
func (r *AdminRepository) GetByID(ctx context.Context, id int32) (*model.Admin, error) {
var admin model.Admin
err := r.db.WithContext(ctx).First(&admin, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &admin, nil
}
func (r *AdminRepository) GetByUsername(ctx context.Context, username string) (*model.Admin, error) {
var admin model.Admin
err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &admin, nil
}
func (r *AdminRepository) Update(ctx context.Context, admin *model.Admin) error {
return r.db.WithContext(ctx).Save(admin).Error
}

View File

@@ -0,0 +1,67 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type FileRepository struct {
db *gorm.DB
}
func NewFileRepository(db *gorm.DB) *FileRepository {
return &FileRepository{db: db}
}
func (r *FileRepository) Create(ctx context.Context, file *model.File) error {
return r.db.WithContext(ctx).Create(file).Error
}
func (r *FileRepository) GetByID(ctx context.Context, id int32) (*model.File, error) {
var file model.File
err := r.db.WithContext(ctx).First(&file, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &file, nil
}
func (r *FileRepository) Delete(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.File{}, id).Error
}
func (r *FileRepository) GetListByUploader(ctx context.Context, uploaderType string, uploaderID int32, page, pageSize int) ([]*model.File, int64, error) {
var files []*model.File
var total int64
query := r.db.WithContext(ctx).Model(&model.File{})
if uploaderType != "" {
query = query.Where("uploader_type = ?", uploaderType)
}
if uploaderID > 0 {
query = query.Where("uploader_id = ?", uploaderID)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&files).Error
if err != nil {
return nil, 0, err
}
return files, total, nil
}
func (r *FileRepository) Update(ctx context.Context, file *model.File) error {
return r.db.WithContext(ctx).Save(file).Error
}

View File

@@ -0,0 +1,108 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type LibraryRepository struct {
db *gorm.DB
}
func NewLibraryRepository(db *gorm.DB) *LibraryRepository {
return &LibraryRepository{db: db}
}
// Category operations
func (r *LibraryRepository) CreateCategory(ctx context.Context, category *model.LibraryCategory) error {
return r.db.WithContext(ctx).Create(category).Error
}
func (r *LibraryRepository) GetCategoryByID(ctx context.Context, id int32) (*model.LibraryCategory, error) {
var category model.LibraryCategory
err := r.db.WithContext(ctx).First(&category, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &category, nil
}
func (r *LibraryRepository) GetAllCategories(ctx context.Context) ([]*model.LibraryCategory, error) {
var categories []*model.LibraryCategory
err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&categories).Error
return categories, err
}
func (r *LibraryRepository) UpdateCategory(ctx context.Context, category *model.LibraryCategory) error {
return r.db.WithContext(ctx).Save(category).Error
}
func (r *LibraryRepository) DeleteCategory(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.LibraryCategory{}, id).Error
}
// LibraryFile operations
func (r *LibraryRepository) CreateFile(ctx context.Context, file *model.LibraryFile) error {
return r.db.WithContext(ctx).Create(file).Error
}
func (r *LibraryRepository) GetFileByID(ctx context.Context, id int32) (*model.LibraryFile, error) {
var file model.LibraryFile
err := r.db.WithContext(ctx).Preload("File").First(&file, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &file, nil
}
func (r *LibraryRepository) GetFilesByCategory(ctx context.Context, categoryID int32, page, pageSize int) ([]*model.LibraryFile, int64, error) {
var files []*model.LibraryFile
var total int64
query := r.db.WithContext(ctx).Model(&model.LibraryFile{}).Where("category_id = ?", categoryID)
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("File").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&files).Error
if err != nil {
return nil, 0, err
}
return files, total, nil
}
func (r *LibraryRepository) DeleteFile(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Get file to delete underlying file record
var libFile model.LibraryFile
if err := tx.First(&libFile, id).Error; err != nil {
return err
}
// Delete library file
if err := tx.Delete(&libFile).Error; err != nil {
return err
}
// Delete underlying file
if err := tx.Delete(&model.File{}, libFile.FileID).Error; err != nil {
return err
}
return nil
})
}
func (r *LibraryRepository) IncrementDownloadCount(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Model(&model.LibraryFile{}).Where("id = ?", id).
Update("download_count", gorm.Expr("download_count + 1")).Error
}

View File

@@ -0,0 +1,162 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type OrderRepository struct {
db *gorm.DB
}
func NewOrderRepository(db *gorm.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) Create(ctx context.Context, order *model.Order) error {
return r.db.WithContext(ctx).Create(order).Error
}
func (r *OrderRepository) CreateWithItems(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
for _, item := range items {
item.OrderID = order.ID
if err := tx.Create(item).Error; err != nil {
return err
}
}
return nil
})
}
func (r *OrderRepository) GetByID(ctx context.Context, id int32) (*model.Order, error) {
var order model.Order
err := r.db.WithContext(ctx).Preload("Items").Preload("Items.File").Preload("Payment").
First(&order, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *OrderRepository) GetByOrderNo(ctx context.Context, orderNo string) (*model.Order, error) {
var order model.Order
err := r.db.WithContext(ctx).Preload("Items").Preload("Items.File").Preload("Items.Printer").Preload("Payment").
Where("order_no = ?", orderNo).First(&order).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *OrderRepository) GetListByUserID(ctx context.Context, userID int32, status int8, page, pageSize int) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := r.db.WithContext(ctx).Model(&model.Order{}).Where("user_id = ?", userID)
if status >= 0 {
query = query.Where("status = ?", status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("Items").Preload("Items.File").
Offset(offset).Limit(pageSize).Order("created_at DESC").
Find(&orders).Error
if err != nil {
return nil, 0, err
}
return orders, total, nil
}
func (r *OrderRepository) UpdateStatus(ctx context.Context, orderNo string, status int8) error {
updates := map[string]interface{}{
"status": status,
}
if status == model.OrderStatusPaid {
now := time.Now()
updates["paid_at"] = &now
}
return r.db.WithContext(ctx).Model(&model.Order{}).
Where("order_no = ?", orderNo).Updates(updates).Error
}
func (r *OrderRepository) GetStatistics(ctx context.Context) (totalUsers, totalOrders, todayOrders int32, todayRevenue float64, pendingOrders int32, err error) {
// 总用户数
if err = r.db.WithContext(ctx).Model(&model.User{}).Count(&totalUsers).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count users failed: %w", err)
}
// 总订单数
if err = r.db.WithContext(ctx).Model(&model.Order{}).Count(&totalOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count orders failed: %w", err)
}
// 待处理订单数
if err = r.db.WithContext(ctx).Model(&model.Order{}).Where("status IN ?", []int8{0, 1, 2}).Count(&pendingOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count pending orders failed: %w", err)
}
// 今日订单数
today := time.Now().Format("2006-01-02")
if err = r.db.WithContext(ctx).Model(&model.Order{}).
Where("DATE(created_at) = ? AND status = ?", today, model.OrderStatusPaid).
Count(&todayOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count today orders failed: %w", err)
}
// 今日收入
if err = r.db.WithContext(ctx).Model(&model.Order{}).
Select("COALESCE(SUM(total_amount), 0)").
Where("DATE(created_at) = ? AND status = ?", today, model.OrderStatusPaid).
Scan(&todayRevenue).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("sum today revenue failed: %w", err)
}
return
}
type OrderItemRepository struct {
db *gorm.DB
}
func NewOrderItemRepository(db *gorm.DB) *OrderItemRepository {
return &OrderItemRepository{db: db}
}
func (r *OrderItemRepository) GetByID(ctx context.Context, id int32) (*model.OrderItem, error) {
var item model.OrderItem
err := r.db.WithContext(ctx).Preload("File").First(&item, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *OrderItemRepository) Create(ctx context.Context, item *model.OrderItem) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *OrderItemRepository) GetByOrderID(ctx context.Context, orderID int32) ([]*model.OrderItem, error) {
var items []*model.OrderItem
err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Find(&items).Error
return items, err
}

View File

@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type PriceRepository struct {
db *gorm.DB
}
func NewPriceRepository(db *gorm.DB) *PriceRepository {
return &PriceRepository{db: db}
}
func (r *PriceRepository) Create(ctx context.Context, item *model.PriceList) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *PriceRepository) GetByID(ctx context.Context, id int32) (*model.PriceList, error) {
var item model.PriceList
err := r.db.WithContext(ctx).First(&item, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *PriceRepository) GetAllActive(ctx context.Context) ([]*model.PriceList, error) {
var items []*model.PriceList
err := r.db.WithContext(ctx).Where("is_active = ?", true).Order("sort_order ASC, id ASC").Find(&items).Error
return items, err
}
func (r *PriceRepository) Update(ctx context.Context, item *model.PriceList) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *PriceRepository) Delete(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.PriceList{}, id).Error
}
type PaymentRepository struct {
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) *PaymentRepository {
return &PaymentRepository{db: db}
}
func (r *PaymentRepository) Create(ctx context.Context, payment *model.Payment) error {
return r.db.WithContext(ctx).Create(payment).Error
}
func (r *PaymentRepository) GetByOrderNo(ctx context.Context, orderNo string) (*model.Payment, error) {
var payment model.Payment
err := r.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&payment).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &payment, nil
}
func (r *PaymentRepository) UpdateStatus(ctx context.Context, orderNo string, status int8, transactionID string) error {
updates := map[string]interface{}{
"pay_status": status,
"transaction_id": transactionID,
}
return r.db.WithContext(ctx).Model(&model.Payment{}).
Where("order_no = ?", orderNo).Updates(updates).Error
}

View File

@@ -0,0 +1,150 @@
package repository
import (
"context"
"errors"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type PrinterRepository struct {
db *gorm.DB
}
func NewPrinterRepository(db *gorm.DB) *PrinterRepository {
return &PrinterRepository{db: db}
}
func (r *PrinterRepository) Create(ctx context.Context, printer *model.Printer) error {
return r.db.WithContext(ctx).Create(printer).Error
}
func (r *PrinterRepository) GetByID(ctx context.Context, id int32) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).First(&printer, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetByName(ctx context.Context, name string) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).Where("name = ?", name).First(&printer).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetByAPIKey(ctx context.Context, apiKey string) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).Where("api_key = ?", apiKey).First(&printer).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetAllActive(ctx context.Context) ([]*model.Printer, error) {
var printers []*model.Printer
err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&printers).Error
return printers, err
}
func (r *PrinterRepository) UpdateStatus(ctx context.Context, id int32, status int8) error {
return r.db.WithContext(ctx).Model(&model.Printer{}).Where("id = ?", id).Update("status", status).Error
}
func (r *PrinterRepository) UpdateHeartbeat(ctx context.Context, id int32) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.Printer{}).Where("id = ?", id).Update("last_heartbeat", &now).Error
}
func (r *PrinterRepository) CountActive(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Printer{}).Where("is_active = ? AND status != ?", true, model.PrinterStatusOffline).Count(&count).Error
return count, err
}
type PrintJobRepository struct {
db *gorm.DB
}
func NewPrintJobRepository(db *gorm.DB) *PrintJobRepository {
return &PrintJobRepository{db: db}
}
func (r *PrintJobRepository) Create(ctx context.Context, job *model.PrintJob) error {
return r.db.WithContext(ctx).Create(job).Error
}
func (r *PrintJobRepository) GetByID(ctx context.Context, id int32) (*model.PrintJob, error) {
var job model.PrintJob
err := r.db.WithContext(ctx).First(&job, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (r *PrintJobRepository) GetByJobNo(ctx context.Context, jobNo string) (*model.PrintJob, error) {
var job model.PrintJob
err := r.db.WithContext(ctx).Where("job_no = ?", jobNo).First(&job).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (r *PrintJobRepository) GetPendingJobs(ctx context.Context, printerID int32) ([]*model.PrintJob, error) {
var jobs []*model.PrintJob
err := r.db.WithContext(ctx).
Where("printer_id = ? AND status = ?", printerID, model.PrintJobStatusPending).
Preload("OrderItem").
Preload("OrderItem.File").
Find(&jobs).Error
return jobs, err
}
func (r *PrintJobRepository) UpdateStatus(ctx context.Context, jobNo string, status string, progress int, errorMsg string) error {
updates := map[string]interface{}{
"status": status,
"progress": progress,
"error_msg": errorMsg,
}
if status == model.PrintJobStatusPrinting {
now := time.Now()
updates["started_at"] = &now
} else if status == model.PrintJobStatusCompleted || status == model.PrintJobStatusFailed {
now := time.Now()
updates["completed_at"] = &now
}
return r.db.WithContext(ctx).Model(&model.PrintJob{}).
Where("job_no = ?", jobNo).Updates(updates).Error
}
func (r *PrintJobRepository) GetQueueCount(ctx context.Context, printerID int32) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.PrintJob{}).
Where("printer_id = ? AND status IN ?", printerID, []string{model.PrintJobStatusPending, model.PrintJobStatusPrinting}).
Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,64 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *UserRepository) GetByID(ctx context.Context, id int32) (*model.User, error) {
var user model.User
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByOpenid(ctx context.Context, openid string) (*model.User, error) {
var user model.User
err := r.db.WithContext(ctx).Where("openid = ?", openid).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *UserRepository) UpdateBalance(ctx context.Context, userID int32, balance float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("balance", balance).Error
}
func (r *UserRepository) DeductBalance(ctx context.Context, userID int32, amount float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).
Where("id = ? AND balance >= ?", userID, amount).
Update("balance", gorm.Expr("balance - ?", amount)).Error
}
func (r *UserRepository) AddBalance(ctx context.Context, userID int32, amount float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
Update("balance", gorm.Expr("balance + ?", amount)).Error
}

View File

@@ -0,0 +1,189 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/cloudprint/cloudprint-backend/config"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
userRepo *repository.UserRepository
adminRepo *repository.AdminRepository
jwtManager *jwt.JWTManager
redis *RedisClient
wxConfig *config.WeChatConfig
}
type RedisClient struct {
// In production, use actual Redis client
}
func NewAuthService(
userRepo *repository.UserRepository,
adminRepo *repository.AdminRepository,
jwtManager *jwt.JWTManager,
redis *RedisClient,
wxConfig *config.WeChatConfig,
) *AuthService {
return &AuthService{
userRepo: userRepo,
adminRepo: adminRepo,
jwtManager: jwtManager,
redis: redis,
wxConfig: wxConfig,
}
}
// WeChat code exchange response
type WeChatCodeResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid,omitempty"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
func (s *AuthService) Login(ctx context.Context, code string) (*LoginResult, error) {
// Exchange code for openid from WeChat
openid, err := s.getWeChatOpenID(code)
if err != nil {
return nil, fmt.Errorf("failed to get wechat openid: %w", err)
}
// Find or create user
user, err := s.userRepo.GetByOpenid(ctx, openid)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
isNewUser := false
if user == nil {
isNewUser = true
user = &model.User{
Openid: openid,
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, "user", "")
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID, "user")
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
return &LoginResult{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: user,
IsNewUser: isNewUser,
}, nil
}
func (s *AuthService) getWeChatOpenID(code string) (string, error) {
url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
s.wxConfig.AppID, s.wxConfig.AppSecret, code)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result WeChatCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.ErrCode != 0 {
return "", fmt.Errorf("wechat error: %s", result.ErrMsg)
}
return result.OpenID, nil
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
return s.jwtManager.RefreshAccessToken(refreshToken)
}
func (s *AuthService) GetUserInfo(ctx context.Context, userID int32) (*model.User, error) {
return s.userRepo.GetByID(ctx, userID)
}
func (s *AuthService) UpdateUser(ctx context.Context, userID int32, nickname, avatar string) (*model.User, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil || user == nil {
return nil, errors.New("user not found")
}
user.Nickname = nickname
if avatar != "" {
user.Avatar = avatar
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
type LoginResult struct {
AccessToken string
RefreshToken string
User *model.User
IsNewUser bool
}
// Admin login
func (s *AuthService) AdminLogin(ctx context.Context, username, password string) (*AdminLoginResult, error) {
admin, err := s.adminRepo.GetByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf("failed to get admin: %w", err)
}
if admin == nil {
return nil, errors.New("invalid credentials")
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
// Generate token
accessToken, err := s.jwtManager.GenerateAccessToken(admin.ID, "admin", admin.Username)
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
return &AdminLoginResult{
AccessToken: accessToken,
Admin: admin,
}, nil
}
type AdminLoginResult struct {
AccessToken string
Admin *model.Admin
}
// HashPassword creates a bcrypt hash of the password
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}

View File

@@ -0,0 +1,105 @@
package service
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/pkg/upload"
)
type FileService struct {
fileRepo *repository.FileRepository
uploader *upload.FileUploader
uploadDir string
}
func NewFileService(fileRepo *repository.FileRepository, uploader *upload.FileUploader, uploadDir string) *FileService {
return &FileService{
fileRepo: fileRepo,
uploader: uploader,
uploadDir: uploadDir,
}
}
func (s *FileService) Upload(ctx context.Context, fileName string, fileType string, uploaderType string, uploaderID int32, data []byte) (*model.File, error) {
if !s.uploader.ValidateFileType(fileName) {
return nil, fmt.Errorf("file type not allowed")
}
// Save file
filePath := s.uploader.GenerateFilePath(fileName)
fullPath := s.uploader.GetFilePath(filePath)
if err := os.WriteFile(fullPath, data, 0644); err != nil {
return nil, fmt.Errorf("failed to write file: %w", err)
}
// Create file record
file := &model.File{
FileName: fileName,
FilePath: filePath,
FileType: strings.ToLower(fileType),
FileSize: int64(len(data)),
UploaderType: uploaderType,
UploaderID: uploaderID,
}
if err := s.fileRepo.Create(ctx, file); err != nil {
// Clean up file on error
os.Remove(fullPath)
return nil, fmt.Errorf("failed to create file record: %w", err)
}
return file, nil
}
func (s *FileService) GetFile(ctx context.Context, fileID int32) (*model.File, error) {
return s.fileRepo.GetByID(ctx, fileID)
}
func (s *FileService) GetFileReader(ctx context.Context, file *model.File) (io.ReadCloser, error) {
fullPath := filepath.Join(s.uploadDir, file.FilePath)
return os.Open(fullPath)
}
func (s *FileService) DeleteFile(ctx context.Context, fileID int32, uploaderType string, uploaderID int32) error {
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil || file == nil {
return fmt.Errorf("file not found")
}
// Verify ownership
if file.UploaderType != uploaderType || file.UploaderID != uploaderID {
return fmt.Errorf("unauthorized")
}
// Delete physical file
fullPath := s.uploader.GetFilePath(file.FilePath)
os.Remove(fullPath)
// Delete record
return s.fileRepo.Delete(ctx, fileID)
}
func (s *FileService) GetFileList(ctx context.Context, uploaderType string, uploaderID int32, page, pageSize int) ([]*model.File, int64, error) {
return s.fileRepo.GetListByUploader(ctx, uploaderType, uploaderID, page, pageSize)
}
func (s *FileService) AddWatermark(ctx context.Context, fileID int32, text string, opacity float32, position int, fontSize int) (*model.File, error) {
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil || file == nil {
return nil, fmt.Errorf("file not found")
}
// In production, use a PDF library to add watermark
// For now, just return the original file
// TODO: Implement actual watermark using pdf-lib or similar
return file, nil
}

View File

@@ -0,0 +1,218 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/google/uuid"
)
type OrderService struct {
orderRepo *repository.OrderRepository
orderItemRepo *repository.OrderItemRepository
fileRepo *repository.FileRepository
priceRepo *repository.PriceRepository
userRepo *repository.UserRepository
printerRepo *repository.PrinterRepository
printJobRepo *repository.PrintJobRepository
}
func NewOrderService(
orderRepo *repository.OrderRepository,
orderItemRepo *repository.OrderItemRepository,
fileRepo *repository.FileRepository,
priceRepo *repository.PriceRepository,
userRepo *repository.UserRepository,
printerRepo *repository.PrinterRepository,
printJobRepo *repository.PrintJobRepository,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
orderItemRepo: orderItemRepo,
fileRepo: fileRepo,
priceRepo: priceRepo,
userRepo: userRepo,
printerRepo: printerRepo,
printJobRepo: printJobRepo,
}
}
type CreateOrderItem struct {
FileID int32
PrinterID int32
Copies int
Color bool
Duplex bool
PageRange string
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int32, items []*CreateOrderItem, remark string) (*model.Order, error) {
if len(items) == 0 {
return nil, errors.New("order must have at least one item")
}
// Generate order number
orderNo := generateOrderNo()
// Calculate total amount
var totalAmount float64
orderItems := make([]*model.OrderItem, 0, len(items))
for _, item := range items {
// Verify file exists
file, err := s.fileRepo.GetByID(ctx, item.FileID)
if err != nil || file == nil {
return nil, fmt.Errorf("file not found: %d", item.FileID)
}
// Calculate price
price, err := s.calculatePrice(ctx, item)
if err != nil {
return nil, err
}
orderItem := &model.OrderItem{
FileID: item.FileID,
PrinterID: intPtr(item.PrinterID),
Copies: item.Copies,
Color: item.Color,
Duplex: item.Duplex,
PageRange: item.PageRange,
Price: price,
}
orderItems = append(orderItems, orderItem)
totalAmount += price
}
// Create order
order := &model.Order{
OrderNo: orderNo,
UserID: userID,
Status: model.OrderStatusPending,
TotalAmount: totalAmount,
Remark: remark,
Items: orderItems,
}
// Save order with items
if err := s.orderRepo.CreateWithItems(ctx, order, orderItems); err != nil {
return nil, fmt.Errorf("failed to create order: %w", err)
}
return order, nil
}
func (s *OrderService) calculatePrice(ctx context.Context, item *CreateOrderItem) (float64, error) {
// Get price list
prices, err := s.priceRepo.GetAllActive(ctx)
if err != nil {
return 0, err
}
// Default price calculation (A4 B&W per page)
defaultPrice := 0.10
colorMultiplier := 3.0
duplexDiscount := 0.8
// Calculate based on file type and settings
// For simplicity, using default values
// In production, look up from price list based on file type
pageCount := 1 // Default, should get actual page count from file
if item.Copies <= 0 {
item.Copies = 1
}
price := float64(pageCount) * defaultPrice * float64(item.Copies)
if item.Color {
price *= colorMultiplier
}
if item.Duplex {
price *= duplexDiscount
}
return price, nil
}
func generateOrderNo() string {
now := time.Now()
return fmt.Sprintf("CP%d%s", now.UnixNano()/1000000, uuid.New().String()[:8])
}
func intPtr(i int32) *int32 {
return &i
}
func (s *OrderService) GetOrder(ctx context.Context, orderNo string) (*model.Order, error) {
return s.orderRepo.GetByOrderNo(ctx, orderNo)
}
func (s *OrderService) GetOrderList(ctx context.Context, userID int32, status int8, page, pageSize int) ([]*model.Order, int64, error) {
return s.orderRepo.GetListByUserID(ctx, userID, status, page, pageSize)
}
func (s *OrderService) UpdateOrderStatus(ctx context.Context, orderNo string, status int8) error {
return s.orderRepo.UpdateStatus(ctx, orderNo, status)
}
func (s *OrderService) CancelOrder(ctx context.Context, orderNo string) error {
order, err := s.orderRepo.GetByOrderNo(ctx, orderNo)
if err != nil || order == nil {
return errors.New("order not found")
}
if order.Status != model.OrderStatusPending {
return errors.New("only pending orders can be cancelled")
}
return s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusCancelled)
}
// PayOrder handles payment for an order
func (s *OrderService) PayOrder(ctx context.Context, orderNo string, payType string) error {
order, err := s.orderRepo.GetByOrderNo(ctx, orderNo)
if err != nil || order == nil {
return errors.New("order not found")
}
if order.Status != model.OrderStatusPending {
return errors.New("order is not pending")
}
// Update order status to paid
if err := s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusPaid); err != nil {
return err
}
// Create print jobs for each order item
for _, item := range order.Items {
if item.PrinterID != nil {
jobNo := generateJobNo()
job := &model.PrintJob{
JobNo: jobNo,
PrinterID: *item.PrinterID,
OrderItemID: item.ID,
Status: model.PrintJobStatusPending,
Progress: 0,
}
if err := s.printJobRepo.Create(ctx, job); err != nil {
// Log error but don't fail
continue
}
}
}
// Update order status to printing
return s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusPrinting)
}
func generateJobNo() string {
now := time.Now()
return fmt.Sprintf("PJ%d%s", now.UnixNano()/1000000, uuid.New().String()[:8])
}

View File

@@ -0,0 +1,194 @@
package service
import (
"context"
"sync"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/google/uuid"
)
type PrintService struct {
printerRepo *repository.PrinterRepository
printJobRepo *repository.PrintJobRepository
orderRepo *repository.OrderRepository
subscribers map[int32]chan *model.PrintJob
mu sync.RWMutex
}
func NewPrintService(
printerRepo *repository.PrinterRepository,
printJobRepo *repository.PrintJobRepository,
orderRepo *repository.OrderRepository,
) *PrintService {
s := &PrintService{
printerRepo: printerRepo,
printJobRepo: printJobRepo,
orderRepo: orderRepo,
subscribers: make(map[int32]chan *model.PrintJob),
}
// Start heartbeat checker
go s.checkPrinterHeartbeat()
return s
}
func (s *PrintService) GetPrinters(ctx context.Context) ([]*model.Printer, error) {
return s.printerRepo.GetAllActive(ctx)
}
func (s *PrintService) GetPrinterStatus(ctx context.Context, printerID int32) (*model.Printer, int64, error) {
printer, err := s.printerRepo.GetByID(ctx, printerID)
if err != nil || printer == nil {
return nil, 0, err
}
queueCount, err := s.printJobRepo.GetQueueCount(ctx, printerID)
if err != nil {
return nil, 0, err
}
return printer, queueCount, nil
}
func (s *PrintService) RegisterPrinter(ctx context.Context, name, displayName, apiKey string) (*model.Printer, string, error) {
// Check if printer already exists
existing, err := s.printerRepo.GetByName(ctx, name)
if err != nil {
return nil, "", err
}
if existing != nil {
return nil, "", nil
}
// Generate token for this printer
token := uuid.New().String()
printer := &model.Printer{
Name: name,
DisplayName: displayName,
APIKey: apiKey,
IsActive: true,
Status: model.PrinterStatusIdle,
}
if err := s.printerRepo.Create(ctx, printer); err != nil {
return nil, "", err
}
return printer, token, nil
}
func (s *PrintService) PrinterHeartbeat(ctx context.Context, printerID int32, status int8, token string) error {
// Verify token (in production, validate against stored token)
// For now, just update heartbeat and status
if err := s.printerRepo.UpdateHeartbeat(ctx, printerID); err != nil {
return err
}
return s.printerRepo.UpdateStatus(ctx, printerID, status)
}
func (s *PrintService) SubmitPrintJob(ctx context.Context, orderItemID int32, printerID int32, filePath string, copies int, color, duplex bool, pageRange string) (*model.PrintJob, error) {
jobNo := generateJobNo()
job := &model.PrintJob{
JobNo: jobNo,
PrinterID: printerID,
OrderItemID: orderItemID,
Status: model.PrintJobStatusPending,
Progress: 0,
}
if err := s.printJobRepo.Create(ctx, job); err != nil {
return nil, err
}
// Notify subscriber
s.mu.RLock()
ch, ok := s.subscribers[printerID]
s.mu.RUnlock()
if ok {
select {
case ch <- job:
default:
}
}
return job, nil
}
func (s *PrintService) CancelPrintJob(ctx context.Context, jobNo string) error {
return s.printJobRepo.UpdateStatus(ctx, jobNo, model.PrintJobStatusCancelled, 0, "cancelled by user")
}
func (s *PrintService) SubscribeJobs(ctx context.Context, printerID int32, token string) (<-chan *model.PrintJob, error) {
ch := make(chan *model.PrintJob, 100)
s.mu.Lock()
s.subscribers[printerID] = ch
s.mu.Unlock()
// Send pending jobs immediately
go func() {
jobs, err := s.printJobRepo.GetPendingJobs(ctx, printerID)
if err != nil {
return
}
for _, job := range jobs {
select {
case ch <- job:
default:
}
}
}()
return ch, nil
}
func (s *PrintService) UnsubscribeJobs(printerID int32) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.subscribers[printerID]; ok {
close(ch)
delete(s.subscribers, printerID)
}
}
func (s *PrintService) ReportJobStatus(ctx context.Context, jobNo string, status string, progress int, errorMsg string) error {
// Update job status
if err := s.printJobRepo.UpdateStatus(ctx, jobNo, status, progress, errorMsg); err != nil {
return err
}
// If completed, check if all jobs for the order are done
if status == model.PrintJobStatusCompleted {
job, _ := s.printJobRepo.GetByJobNo(ctx, jobNo)
if job != nil {
// Check if all jobs for the order are completed
s.checkOrderCompletion(ctx, job.OrderItemID)
}
}
return nil
}
func (s *PrintService) checkOrderCompletion(ctx context.Context, orderItemID int32) {
// In production, check if all print jobs for the order are completed
// and update order status accordingly
}
func (s *PrintService) checkPrinterHeartbeat() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
// In production, check LastHeartbeat and mark printers as offline if no heartbeat
// This is a placeholder for the heartbeat checker
}
}
// ValidatePrinterToken validates the API key and returns the printer
func (s *PrintService) ValidatePrinterToken(ctx context.Context, apiKey string) (*model.Printer, error) {
return s.printerRepo.GetByAPIKey(ctx, apiKey)
}

View File

@@ -0,0 +1,105 @@
package jwt
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("token has expired")
)
type Claims struct {
UserID int32 `json:"user_id"`
UserType string `json:"user_type"` // "user" or "admin"
Username string `json:"username,omitempty"`
jwt.RegisteredClaims
}
type JWTManager struct {
secretKey []byte
accessTokenTTL time.Duration
refreshTokenTTL time.Duration
}
func NewJWTManager(secret string, accessTokenTTL, refreshTokenTTL time.Duration) *JWTManager {
return &JWTManager{
secretKey: []byte(secret),
accessTokenTTL: accessTokenTTL,
refreshTokenTTL: refreshTokenTTL,
}
}
func (m *JWTManager) GenerateAccessToken(userID int32, userType string, username string) (string, error) {
now := time.Now()
claims := &Claims{
UserID: userID,
UserType: userType,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secretKey)
}
func (m *JWTManager) GenerateRefreshToken(userID int32, userType string) (string, error) {
now := time.Now()
claims := &Claims{
UserID: userID,
UserType: userType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Subject: "refresh",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secretKey)
}
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return m.secretKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
func (m *JWTManager) RefreshAccessToken(refreshToken string) (string, error) {
claims, err := m.ValidateToken(refreshToken)
if err != nil {
return "", err
}
// Only refresh tokens with subject "refresh" can be used
if claims.Subject != "refresh" {
return "", ErrInvalidToken
}
return m.GenerateAccessToken(claims.UserID, claims.UserType, claims.Username)
}

View File

@@ -0,0 +1,200 @@
package pay
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/xml"
"fmt"
"math/rand"
"sort"
"strings"
"time"
"github.com/cloudprint/cloudprint-backend/config"
"github.com/iGoogle-ink/gopay"
)
// RandomString generates a random string with given length
func RandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
type WeChatPay struct {
client *gopay.WxPayClient
config *config.WeChatConfig
}
type UnifiedOrderResponse struct {
ReturnCode string `xml:"return_code"`
ReturnMsg string `xml:"return_msg"`
ResultCode string `xml:"result_code"`
PrepayID string `xml:"prepay_id,omitempty"`
CodeURL string `xml:"code_url,omitempty"`
MWebURL string `xml:"mweb_url,omitempty"`
ErrCode string `xml:"err_code,omitempty"`
ErrMsg string `xml:"err_msg,omitempty"`
TransactionID string `xml:"transaction_id,omitempty"`
TradeState string `xml:"trade_state,omitempty"`
TotalFee int `xml:"total_fee,omitempty"`
}
type PayCallback struct {
ReturnCode string `xml:"return_code"`
ReturnMsg string `xml:"return_msg"`
AppID string `xml:"appid"`
MchID string `xml:"mch_id"`
NonceStr string `xml:"nonce_str"`
Sign string `xml:"sign"`
ResultCode string `xml:"result_code"`
TransactionID string `xml:"transaction_id"`
OutTradeNo string `xml:"out_trade_no"`
TotalFee int `xml:"total_fee"`
TimeEnd string `xml:"time_end"`
}
func NewWeChatPay(cfg *config.WeChatConfig) *WeChatPay {
client := gopay.NewWxPayClient(cfg.AppID, cfg.MchID, cfg.ApiKey, cfg.NotifyURL)
if cfg.CertPath != "" {
client.SetCertFilePath(cfg.CertPath)
}
return &WeChatPay{
client: client,
config: cfg,
}
}
func (p *WeChatPay) createSign(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var signParts []string
for _, k := range keys {
if params[k] != "" && k != "sign" {
signParts = append(signParts, fmt.Sprintf("%s=%s", k, params[k]))
}
}
signStr := strings.Join(signParts, "&")
signStr += "&key=" + p.config.ApiKey
md5Hash := md5.New()
md5Hash.Write([]byte(signStr))
return strings.ToUpper(hex.EncodeToString(md5Hash.Sum(nil)))
}
func (p *WeChatPay) UnifiedOrder(ctx context.Context, orderNo string, amount float64, description, openID, clientIP string) (*UnifiedOrderResponse, error) {
nonceStr := RandomString(32)
totalFee := int(amount * 100) // Convert to fen
// Build request body
body := gopay.UnifiedOrder{
AppID: p.config.AppID,
MchID: p.config.MchID,
NonceStr: nonceStr,
Body: description,
OutTradeNo: orderNo,
TotalFee: totalFee,
SpbillCreateIP: clientIP,
NotifyURL: p.config.NotifyURL,
TradeType: "JSAPI",
OpenID: openID,
}
// Use go-pay client to call unified order
resp, err := p.client.UnifiedOrder(ctx, body)
if err != nil {
return nil, fmt.Errorf("unified order failed: %w", err)
}
result := &UnifiedOrderResponse{
ReturnCode: resp.GetString("return_code"),
ReturnMsg: resp.GetString("return_msg"),
ResultCode: resp.GetString("result_code"),
PrepayID: resp.GetString("prepay_id"),
CodeURL: resp.GetString("code_url"),
MWebURL: resp.GetString("mweb_url"),
ErrCode: resp.GetString("err_code"),
ErrMsg: resp.GetString("err_msg"),
}
return result, nil
}
func (p *WeChatPay) QueryOrder(ctx context.Context, orderNo string) (string, int, error) {
nonceStr := RandomString(32)
body := gopay.QueryOrder{
AppID: p.config.AppID,
MchID: p.config.MchID,
NonceStr: nonceStr,
OutTradeNo: orderNo,
}
resp, err := p.client.QueryOrder(ctx, body)
if err != nil {
return "", 0, err
}
tradeState := resp.GetString("trade_state")
totalFee := resp.GetInt("total_fee")
return tradeState, totalFee, nil
}
func (p *WeChatPay) ParseCallback(body []byte) (*PayCallback, error) {
var callback PayCallback
if err := xml.Unmarshal(body, &callback); err != nil {
return nil, fmt.Errorf("failed to parse callback: %w", err)
}
return &callback, nil
}
func (p *WeChatPay) ValidateCallback(callback *PayCallback) bool {
params := map[string]string{
"return_code": callback.ReturnCode,
"return_msg": callback.ReturnMsg,
"appid": callback.AppID,
"mch_id": callback.MchID,
"nonce_str": callback.NonceStr,
"result_code": callback.ResultCode,
"transaction_id": callback.TransactionID,
"out_trade_no": callback.OutTradeNo,
"total_fee": fmt.Sprintf("%d", callback.TotalFee),
"time_end": callback.TimeEnd,
}
expectedSign := p.createSign(params)
return callback.Sign == expectedSign
}
func (p *WeChatPay) GenerateJSAPIPayParams(prepayID string) (map[string]string, error) {
nonceStr := RandomString(32)
timestamp := fmt.Sprintf("%d", time.Now().Unix())
params := map[string]string{
"appId": p.config.AppID,
"timeStamp": timestamp,
"nonceStr": nonceStr,
"package": "prepay_id=" + prepayID,
"signType": "MD5",
}
paySign := p.createSign(map[string]string{
"appId": p.config.AppID,
"timeStamp": timestamp,
"nonceStr": nonceStr,
"package": "prepay_id=" + prepayID,
"signType": "MD5",
})
params["paySign"] = paySign
return params, nil
}

View File

@@ -0,0 +1,196 @@
package upload
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"sync"
"github.com/google/uuid"
)
var (
// AllowedFileTypes 文件类型白名单
AllowedFileTypes = map[string]bool{
"pdf": true,
"doc": true,
"docx": true,
"xls": true,
"xlsx": true,
"ppt": true,
"pptx": true,
"txt": true,
"jpg": true,
"jpeg": true,
"png": true,
"bmp": true,
"gif": true,
"zip": true,
"rar": true,
}
)
type FileUploader struct {
basePath string
chunkPath string
maxSize int64
mu sync.RWMutex
chunks map[string]*ChunkInfo
}
type ChunkInfo struct {
FileName string
TotalParts int
Parts map[int][]byte
CreatedAt int64
}
func NewFileUploader(basePath string, chunkPath string, maxSize int64) *FileUploader {
uploader := &FileUploader{
basePath: basePath,
chunkPath: chunkPath,
maxSize: maxSize,
chunks: make(map[string]*ChunkInfo),
}
// Ensure directories exist
os.MkdirAll(basePath, 0755)
os.MkdirAll(chunkPath, 0755)
return uploader
}
func (u *FileUploader) getExt(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
return strings.TrimPrefix(ext, ".")
}
func (u *FileUploader) ValidateFileType(filename string) bool {
ext := u.getExt(filename)
return AllowedFileTypes[ext]
}
func (u *FileUploader) GenerateFilePath(filename string) string {
ext := u.getExt(filename)
newName := fmt.Sprintf("%s.%s", uuid.New().String(), ext)
// 按日期分目录
// dateDir := time.Now().Format("2006/01/02")
// return filepath.Join(dateDir, newName)
return newName
}
func (u *FileUploader) Upload(ctx context.Context, file *multipart.FileHeader) (string, error) {
if u.maxSize > 0 && file.Size > u.maxSize {
return "", fmt.Errorf("file size exceeds limit: %d > %d", file.Size, u.maxSize)
}
if !u.ValidateFileType(file.Filename) {
return "", fmt.Errorf("file type not allowed: %s", file.Filename)
}
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer src.Close()
filePath := u.GenerateFilePath(file.Filename)
fullPath := filepath.Join(u.basePath, filePath)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err)
}
dst, err := os.Create(fullPath)
if err != nil {
return "", fmt.Errorf("failed to create file: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return filePath, nil
}
// Chunk upload support
func (u *FileUploader) InitChunkUpload(chunkID, fileName string, totalParts int) error {
u.mu.Lock()
defer u.mu.Unlock()
u.chunks[chunkID] = &ChunkInfo{
FileName: fileName,
TotalParts: totalParts,
Parts: make(map[int][]byte),
}
return nil
}
func (u *FileUploader) SaveChunk(chunkID string, partIndex int, data []byte) error {
u.mu.Lock()
defer u.mu.Unlock()
chunk, ok := u.chunks[chunkID]
if !ok {
return fmt.Errorf("chunk upload not initialized: %s", chunkID)
}
if partIndex < 0 || partIndex >= chunk.TotalParts {
return fmt.Errorf("invalid part index: %d", partIndex)
}
chunk.Parts[partIndex] = data
return nil
}
func (u *FileUploader) CompleteChunkUpload(ctx context.Context, chunkID string) (string, error) {
u.mu.Lock()
chunk, ok := u.chunks[chunkID]
if !ok {
u.mu.Unlock()
return "", fmt.Errorf("chunk upload not found: %s", chunkID)
}
if len(chunk.Parts) != chunk.TotalParts {
u.mu.Unlock()
return "", fmt.Errorf("incomplete chunks: %d/%d", len(chunk.Parts), chunk.TotalParts)
}
// Delete chunk info early to allow other operations
delete(u.chunks, chunkID)
u.mu.Unlock()
filePath := u.GenerateFilePath(chunk.FileName)
fullPath := filepath.Join(u.basePath, filePath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err)
}
f, err := os.Create(fullPath)
if err != nil {
return "", fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
for i := 0; i < chunk.TotalParts; i++ {
if _, err := f.Write(chunk.Parts[i]); err != nil {
return "", fmt.Errorf("failed to write chunk %d: %w", i, err)
}
}
return filePath, nil
}
func (u *FileUploader) DeleteFile(filePath string) error {
fullPath := filepath.Join(u.basePath, filePath)
return os.Remove(fullPath)
}
func (u *FileUploader) GetFilePath(filePath string) string {
return filepath.Join(u.basePath, filePath)
}

View File

@@ -0,0 +1,502 @@
syntax = "proto3";
package cloudprint;
option go_package = "github.com/cloudprint/cloudprint-backend/proto/generated";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
// ============ 通用消息 ============
message Empty {}
message StatusResponse {
int32 code = 1;
string message = 2;
}
// ============ 用户相关 ============
message User {
int32 id = 1;
string openid = 2;
string nickname = 3;
string avatar = 4;
double balance = 5;
google.protobuf.Timestamp created_at = 6;
}
message LoginRequest {
string code = 1; // 微信授权码
}
message LoginResponse {
string access_token = 1;
string refresh_token = 2;
User user = 3;
}
message RefreshRequest {
string refresh_token = 1;
}
message RefreshResponse {
string access_token = 1;
}
message GetUserInfoRequest {}
message UpdateUserRequest {
string nickname = 1;
string avatar = 2;
}
// ============ 文件相关 ============
message FileInfo {
int32 id = 1;
string file_name = 2;
string file_path = 3;
string file_type = 4;
int64 file_size = 5;
string uploader_type = 6;
int32 uploader_id = 7;
google.protobuf.Timestamp created_at = 8;
}
message UploadRequest {
string file_name = 1;
string file_type = 2;
int64 file_size = 3;
bytes data = 4;
bool is_last_chunk = 5;
string chunk_id = 6;
int32 chunk_index = 7;
int32 total_chunks = 8;
}
message UploadResponse {
int32 file_id = 1;
string file_path = 2;
string file_url = 3;
}
message GetFileRequest {
int32 file_id = 1;
}
message FileData {
bytes data = 1;
bool is_last = 2;
}
message DeleteFileRequest {
int32 file_id = 1;
}
message WatermarkRequest {
int32 file_id = 1;
string text = 2;
float opacity = 3;
int32 position = 4; // 1: 左上 2: 右上 3: 左下 4: 右下 5: 居中
int32 font_size = 5;
}
message WatermarkResponse {
int32 file_id = 1;
string file_path = 2;
}
message GetFileListRequest {
string uploader_type = 1;
int32 uploader_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message FileListResponse {
repeated FileInfo files = 1;
int32 total = 2;
}
// ============ 订单相关 ============
message Order {
int32 id = 1;
string order_no = 2;
int32 user_id = 3;
int32 status = 4;
double total_amount = 5;
string remark = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp paid_at = 8;
repeated OrderItem items = 9;
}
message OrderItem {
int32 id = 1;
int32 order_id = 2;
int32 file_id = 3;
FileInfo file = 4;
int32 printer_id = 5;
int32 copies = 6;
bool color = 7;
bool duplex = 8;
string page_range = 9;
double price = 10;
}
message CreateOrderRequest {
repeated CreateOrderItem items = 1;
string remark = 2;
}
message CreateOrderItem {
int32 file_id = 1;
int32 printer_id = 2;
int32 copies = 3;
bool color = 4;
bool duplex = 5;
string page_range = 6;
}
message CreateOrderResponse {
string order_no = 1;
double total_amount = 2;
}
message GetOrderRequest {
string order_no = 1;
}
message GetOrderListRequest {
int32 user_id = 1;
int32 status = 2;
int32 page = 3;
int32 page_size = 4;
}
message OrderListResponse {
repeated Order orders = 1;
int32 total = 2;
}
message UpdateOrderStatusRequest {
string order_no = 1;
int32 status = 2;
}
message CancelOrderRequest {
string order_no = 1;
}
// ============ 打印机相关 ============
message Printer {
int32 id = 1;
string name = 2;
string display_name = 3;
bool is_active = 4;
int32 status = 5; // 0: 空闲 1: 打印中 2: 离线
google.protobuf.Timestamp last_heartbeat = 6;
}
message GetPrintersRequest {}
message PrinterListResponse {
repeated Printer printers = 1;
}
message GetPrinterStatusRequest {
int32 printer_id = 1;
}
message PrinterStatusResponse {
Printer printer = 1;
int32 queue_count = 2;
}
message RegisterPrinterRequest {
string name = 1;
string display_name = 2;
string api_key = 3;
}
message RegisterPrinterResponse {
int32 printer_id = 1;
string token = 2;
}
message PrinterHeartbeatRequest {
int32 printer_id = 1;
int32 status = 2;
string token = 3;
}
// ============ 打印任务相关 ============
message PrintJob {
int32 id = 1;
string job_no = 2;
int32 printer_id = 3;
int32 order_item_id = 4;
string status = 5; // pending/printing/completed/failed/cancelled
int32 progress = 6;
string error_msg = 7;
google.protobuf.Timestamp started_at = 8;
google.protobuf.Timestamp completed_at = 9;
OrderItem order_item = 10;
}
message SubmitPrintJobRequest {
int32 order_item_id = 1;
int32 printer_id = 2;
string file_path = 3;
int32 copies = 4;
bool color = 5;
bool duplex = 6;
string page_range = 7;
}
message SubmitPrintJobResponse {
string job_no = 1;
}
message CancelPrintJobRequest {
string job_no = 1;
}
message SubscribeJobsRequest {
int32 printer_id = 1;
string token = 2;
}
message ReportJobStatusRequest {
string job_no = 1;
string status = 2;
int32 progress = 3;
string error_msg = 4;
}
// ============ 文库相关 ============
message LibraryCategory {
int32 id = 1;
string name = 2;
string icon = 3;
int32 sort_order = 4;
}
message LibraryFile {
int32 id = 1;
int32 category_id = 2;
int32 file_id = 3;
string title = 4;
string description = 5;
int32 page_count = 6;
int32 download_count = 7;
FileInfo file = 8;
google.protobuf.Timestamp created_at = 9;
}
message GetCategoriesRequest {}
message CategoryListResponse {
repeated LibraryCategory categories = 1;
}
message GetLibraryFilesRequest {
int32 category_id = 1;
int32 page = 2;
int32 page_size = 3;
}
message LibraryFileListResponse {
repeated LibraryFile files = 1;
int32 total = 2;
}
message UploadLibraryFileRequest {
int32 category_id = 1;
string title = 2;
string description = 3;
bytes data = 4;
string file_name = 5;
string file_type = 6;
}
message UploadLibraryFileResponse {
int32 library_file_id = 1;
}
message DeleteLibraryFileRequest {
int32 library_file_id = 1;
}
// ============ 价目表相关 ============
message PriceItem {
int32 id = 1;
string name = 2;
string type = 3;
double price = 4;
string unit = 5;
bool color_enabled = 6;
bool is_active = 7;
}
message GetPriceListRequest {}
message PriceListResponse {
repeated PriceItem items = 1;
}
message CreatePriceItemRequest {
string name = 1;
string type = 2;
double price = 3;
string unit = 4;
bool color_enabled = 5;
}
message UpdatePriceItemRequest {
int32 id = 1;
string name = 2;
string type = 3;
double price = 4;
string unit = 5;
bool color_enabled = 6;
bool is_active = 7;
}
// ============ 支付相关 ============
message WxPayRequest {
string order_no = 1;
double amount = 2;
string description = 3;
string openid = 4;
string client_ip = 5;
}
message WxPayResponse {
string prepay_id = 1;
string code_url = 2;
string mweb_url = 3;
}
message QueryPayStatusRequest {
string order_no = 1;
}
message PayStatusResponse {
int32 status = 1; // 0: 待支付 1: 支付中 2: 成功 3: 失败
string transaction_id = 2;
}
message PayCallbackRequest {
string body = 1;
}
message PayCallbackResponse {
string return_code = 1;
string return_msg = 2;
}
message PayByBalanceRequest {
string order_no = 1;
int32 user_id = 2;
}
// ============ 管理后台相关 ============
message Admin {
int32 id = 1;
string username = 2;
string nickname = 3;
int32 role = 4;
google.protobuf.Timestamp created_at = 5;
}
message AdminLoginRequest {
string username = 1;
string password = 2;
}
message AdminLoginResponse {
string access_token = 1;
Admin admin = 2;
}
message GetStatisticsRequest {}
message Statistics {
int32 total_users = 1;
int32 total_orders = 2;
int32 today_orders = 3;
double today_revenue = 4;
int32 pending_orders = 5;
int32 active_printers = 6;
}
// ============ 服务定义 ============
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc RefreshToken(RefreshRequest) returns (RefreshResponse);
rpc GetUserInfo(GetUserInfoRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
}
service FileService {
rpc Upload(stream UploadRequest) returns (UploadResponse);
rpc GetFile(GetFileRequest) returns (stream FileData);
rpc DeleteFile(DeleteFileRequest) returns (Empty);
rpc AddWatermark(WatermarkRequest) returns (WatermarkResponse);
rpc GetFileList(GetFileListRequest) returns (FileListResponse);
}
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder(GetOrderRequest) returns (Order);
rpc GetOrderList(GetOrderListRequest) returns (OrderListResponse);
rpc UpdateOrderStatus(UpdateOrderStatusRequest) returns (Empty);
rpc CancelOrder(CancelOrderRequest) returns (Empty);
}
service PrintService {
rpc GetPrinters(GetPrintersRequest) returns (PrinterListResponse);
rpc GetPrinterStatus(GetPrinterStatusRequest) returns (PrinterStatusResponse);
rpc RegisterPrinter(RegisterPrinterRequest) returns (RegisterPrinterResponse);
rpc PrinterHeartbeat(PrinterHeartbeatRequest) returns (Empty);
rpc SubmitPrintJob(SubmitPrintJobRequest) returns (SubmitPrintJobResponse);
rpc CancelPrintJob(CancelPrintJobRequest) returns (Empty);
rpc SubscribeJobs(SubscribeJobsRequest) returns (stream PrintJob);
rpc ReportJobStatus(ReportJobStatusRequest) returns (Empty);
}
service LibraryService {
rpc GetCategories(GetCategoriesRequest) returns (CategoryListResponse);
rpc GetFiles(GetLibraryFilesRequest) returns (LibraryFileListResponse);
rpc UploadFile(stream UploadLibraryFileRequest) returns (UploadLibraryFileResponse);
rpc DeleteFile(DeleteLibraryFileRequest) returns (Empty);
}
service PriceService {
rpc GetPriceList(GetPriceListRequest) returns (PriceListResponse);
rpc CreatePriceItem(CreatePriceItemRequest) returns (PriceItem);
rpc UpdatePriceItem(UpdatePriceItemRequest) returns (PriceItem);
}
service PayService {
rpc CreateWxPayOrder(WxPayRequest) returns (WxPayResponse);
rpc QueryPayStatus(QueryPayStatusRequest) returns (PayStatusResponse);
rpc HandlePayCallback(PayCallbackRequest) returns (PayCallbackResponse);
rpc PayByBalance(PayByBalanceRequest) returns (StatusResponse);
}
service AdminService {
rpc Login(AdminLoginRequest) returns (AdminLoginResponse);
rpc GetStatistics(GetStatisticsRequest) returns (Statistics);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
-- 云印享 - 付费打印服务系统数据库初始化脚本
-- 数据库: cloudprint
CREATE DATABASE IF NOT EXISTS `cloudprint` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `cloudprint`;
-- 用户表
CREATE TABLE IF NOT EXISTS `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`openid` VARCHAR(64) UNIQUE NOT NULL COMMENT '微信OpenID',
`nickname` VARCHAR(128) DEFAULT '' COMMENT '昵称',
`avatar` VARCHAR(512) DEFAULT '' COMMENT '头像URL',
`balance` DECIMAL(10,2) DEFAULT 0.00 COMMENT '余额',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 管理员表
CREATE TABLE IF NOT EXISTS `admin` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(64) UNIQUE NOT NULL COMMENT '用户名',
`password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希',
`nickname` VARCHAR(128) DEFAULT '' COMMENT '昵称',
`role` TINYINT DEFAULT 1 COMMENT '1:普通管理员 2:超级管理员',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
-- 打印机表
CREATE TABLE IF NOT EXISTS `printer` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) UNIQUE NOT NULL COMMENT '系统打印机名',
`display_name` VARCHAR(128) NOT NULL COMMENT '显示名称',
`api_key` VARCHAR(64) UNIQUE NOT NULL COMMENT '客户端认证Key',
`is_active` TINYINT DEFAULT 1 COMMENT '是否启用',
`status` TINYINT DEFAULT 0 COMMENT '0:空闲 1:打印中 2:离线',
`last_heartbeat` DATETIME DEFAULT NULL COMMENT '最后心跳时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_name` (`name`),
INDEX `idx_api_key` (`api_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打印机表';
-- 订单表
CREATE TABLE IF NOT EXISTS `order` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
`user_id` INT NOT NULL COMMENT '用户ID',
`status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:已支付 2:打印中 3:已完成 4:已取消',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '总金额',
`remark` VARCHAR(512) DEFAULT '' COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`paid_at` DATETIME DEFAULT NULL COMMENT '支付时间',
INDEX `idx_order_no` (`order_no`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 订单明细表
CREATE TABLE IF NOT EXISTS `order_item` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_id` INT NOT NULL COMMENT '订单ID',
`file_id` INT NOT NULL COMMENT '文件ID',
`printer_id` INT DEFAULT NULL COMMENT '打印机ID',
`copies` INT DEFAULT 1 COMMENT '份数',
`color` TINYINT DEFAULT 0 COMMENT '是否彩色 0:黑白 1:彩色',
`duplex` TINYINT DEFAULT 0 COMMENT '是否双面 0:单面 1:双面',
`page_range` VARCHAR(64) DEFAULT 'all' COMMENT '页面范围',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
INDEX `idx_order_id` (`order_id`),
INDEX `idx_file_id` (`file_id`),
FOREIGN KEY (`order_id`) REFERENCES `order`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
-- 文件表
CREATE TABLE IF NOT EXISTS `file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`file_name` VARCHAR(256) NOT NULL COMMENT '文件名',
`file_path` VARCHAR(512) NOT NULL COMMENT '文件路径',
`file_type` VARCHAR(32) NOT NULL COMMENT '文件类型',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`uploader_type` VARCHAR(16) NOT NULL COMMENT '上传者类型: user/admin/system',
`uploader_id` INT NOT NULL COMMENT '上传者ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_uploader` (`uploader_type`, `uploader_id`),
INDEX `idx_file_type` (`file_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件表';
-- 文库分类表
CREATE TABLE IF NOT EXISTS `library_category` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL COMMENT '分类名称',
`icon` VARCHAR(128) DEFAULT '' COMMENT '图标',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文库分类表';
-- 文库文件表
CREATE TABLE IF NOT EXISTS `library_file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`category_id` INT NOT NULL COMMENT '分类ID',
`file_id` INT NOT NULL COMMENT '文件ID',
`title` VARCHAR(256) NOT NULL COMMENT '标题',
`description` VARCHAR(512) DEFAULT '' COMMENT '描述',
`page_count` INT DEFAULT 0 COMMENT '页数',
`download_count` INT DEFAULT 0 COMMENT '下载次数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_category_id` (`category_id`),
INDEX `idx_file_id` (`file_id`),
FOREIGN KEY (`category_id`) REFERENCES `library_category`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文库文件表';
-- 打印任务表
CREATE TABLE IF NOT EXISTS `print_job` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`job_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '任务号',
`printer_id` INT NOT NULL COMMENT '打印机ID',
`order_item_id` INT NOT NULL COMMENT '订单明细ID',
`status` VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/printing/completed/failed/cancelled',
`progress` INT DEFAULT 0 COMMENT '进度(0-100)',
`error_msg` VARCHAR(512) DEFAULT '' COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
INDEX `idx_job_no` (`job_no`),
INDEX `idx_printer_id` (`printer_id`),
INDEX `idx_status` (`status`),
FOREIGN KEY (`printer_id`) REFERENCES `printer`(`id`) ON DELETE RESTRICT,
FOREIGN KEY (`order_item_id`) REFERENCES `order_item`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打印任务表';
-- 价目表
CREATE TABLE IF NOT EXISTS `price_list` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL COMMENT '项目名称',
`type` VARCHAR(32) NOT NULL COMMENT '纸张类型: A4/A3/照片纸',
`price` DECIMAL(10,2) NOT NULL COMMENT '单价',
`unit` VARCHAR(16) NOT NULL COMMENT '单位: 页/张/份',
`color_enabled` TINYINT DEFAULT 1 COMMENT '是否支持彩色',
`is_active` TINYINT DEFAULT 1 COMMENT '是否启用',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_type` (`type`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价目表';
-- 支付记录表
CREATE TABLE IF NOT EXISTS `payment` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
`pay_type` VARCHAR(16) DEFAULT 'wechat' COMMENT '支付类型: wechat/balance',
`pay_status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:支付中 2:成功 3:失败',
`transaction_id` VARCHAR(64) DEFAULT '' COMMENT '微信交易号',
`paid_at` DATETIME DEFAULT NULL COMMENT '支付时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_order_no` (`order_no`),
INDEX `idx_pay_status` (`pay_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';
-- 初始化管理员账号 (密码: admin123)
-- 密码哈希使用 bcrypt 生成
INSERT INTO `admin` (`username`, `password_hash`, `nickname`, `role`) VALUES
('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrqJlmZ2j3.N9qo8uLOickgx2ZMRZoG', '系统管理员', 2);
-- 初始化默认价目表
INSERT INTO `price_list` (`name`, `type`, `price`, `unit`, `color_enabled`, `is_active`, `sort_order`) VALUES
('A4黑白打印', 'A4', 0.10, '', 0, 1, 1),
('A4彩色打印', 'A4', 0.30, '', 1, 1, 2),
('A3黑白打印', 'A3', 0.20, '', 0, 1, 3),
('A3彩色打印', 'A3', 0.60, '', 1, 1, 4),
('照片打印', '照片纸', 2.00, '', 1, 1, 5);
-- 初始化默认文库分类
INSERT INTO `library_category` (`name`, `icon`, `sort_order`) VALUES
('学习资料', 'book', 1),
('考试真题', 'exam', 2),
('简历模板', 'resume', 3),
('合同协议', 'contract', 4),
('其他资料', 'other', 5);