feat: 添加发票管理模块,支持客户申请发票、管理员处理发票、文件上传及下载功能,更新相关文档和国际化错误码

This commit is contained in:
wood911010 committed 2026-01-23 00:38:00 +08:00
1 parent 3192b79aa4
commit 969b36f1d1
24 files changed
+5621 -274

No files matched your search

+18
View File
@@ -95,6 +95,14 @@ errors:
"900003": "Resource conflict"
"900004": "Internal server error"
# Invoice module (70xxxx)
invoice:
"700001": "Invoice not found"
"700002": "Order not found or access denied"
"700003": "Invoice already exists for this order"
"700004": "Invoice status does not allow this operation"
"700005": "Invoice file not found"
# Common text
common:
success: "Success"
@@ -160,5 +168,15 @@ enums:
"unlock": "Unlock"
"other": "Other"
invoice_status:
"pending": "Pending"
"issued": "Issued"
"rejected": "Rejected"
invoice_type:
"personal": "Personal"
"enterprise": "Enterprise"
"vat_special": "VAT Special Invoice"
# Default error message
default_error: "Unknown error"
+18
View File
@@ -92,6 +92,14 @@ errors:
cu_device:
"620001": "设备不存在或无权限访问"
# 发票模块 (70xxxx)
invoice:
"700001": "发票不存在"
"700002": "订单不存在或无权限访问"
"700003": "该订单已存在发票申请"
"700004": "发票状态不允许操作"
"700005": "发票文件不存在"
# 系统通用 (90xxxx)
system:
"900001": "请求参数无效"
@@ -164,5 +172,15 @@ enums:
"unlock": "解锁"
"other": "其他"
invoice_status:
"pending": "待处理"
"issued": "已开票"
"rejected": "已驳回"
invoice_type:
"personal": "个人"
"enterprise": "企业普票"
"vat_special": "增值税专用发票"
# 默认错误信息
default_error: "未知错误"
@@ -1,140 +1,259 @@
# 发票模块设计
实体关系
订单:发票 1:1
## 1. 背景与目标
客户购买套餐后(订单已支付),需要在用户端发起开票申请;管理员在管理端处理开票、上传发票 PDF,或驳回并给出修改建议;用户可在列表/详情查看处理进度并下载发票文件。
## 表结构设计
发票申请号
订单ID
客户ID
发票金额
开票完成时间
开票状态 待处理 已开票 已驳回
## 2. 实体关系
- `cu_orders`(订单) : `invoices`(发票) = 1 : 1(一个订单最多一张发票)
发票类型 个人,企业,增值税专用发票
发票抬头
纳税人识别号
开票内容
收票邮箱
备注信息
申请人ID
约束:
- `invoices.order_id` 唯一,确保 1:1
发票文件url
上传时间
上传人ID
## 3. 状态与枚举
驳回原因
驳回时间
驳回人ID
建议修改内容
### 3.1 开票状态(invoice_status)
- `pending`:待处理
- `issued`:已开票
- `rejected`:已驳回
创建时间
更新时间
id
状态流转:
- `pending -> issued`
- `pending -> rejected`
- (可选)`rejected -> issued`(管理员确认无误后直接开票)
## 接口
### 3.2 发票类型(invoice_type)
- `personal`:个人
- `enterprise`:企业普票
- `vat_special`:增值税专用发票
### 客户申请发票
字段约束建议:
- `invoice_type=personal`:`taxpayer_id` 可为空
- `invoice_type=enterprise/vat_special`:`taxpayer_id` 必填
请求
订单ID
发票类型
收票邮箱
发票抬头
纳税人识别号
开票内容
备注信息
## 4. 编号与金额口径
### 4.1 发票申请号(invoice_no)
建议生成规则与订单号风格一致:
- `INV{YYYYMMDD}{9位序列}`(序列使用纳秒时间戳后 9 位,确保全局唯一)
### 客户发票列表
### 4.2 发票金额(amount)
强制以订单 `cu_orders.total_amount` 为准(下单时已确定),避免用户端传入金额造成口径不一致。
请求
search 发票号或订单号模糊匹配
开票状态
申请时间(某日)
分页
## 5. 表结构设计(新增 invoices 表)
返回
发票ID
发票申请号
用户名
订单号
发票金额
开票完成时间
开票状态
申请时间
套餐类型 订单的package_name
> 说明:当前项目大量使用软删除(`deleted_at`),发票也建议沿用该模式。
### 客户发票详情
### 5.1 invoices 表(建议)
字段设计(按业务分组):
- 主键:`id`
- 关联:`order_id`(唯一)、`order_no`(冗余快照,便于搜索)、`customer_id`、`cu_user_id`
- 金额:`amount`
- 业务:`status`、`invoice_type`、`title`、`taxpayer_id`、`content`、`receiver_email`、`remark`
- 开票处理:`issued_at`、`invoice_file_url`、`uploaded_at`、`uploaded_by`
- 驳回处理:`rejected_at`、`rejected_by`、`reject_reason`、`suggestion`
- 下载(可选):`download_token`(用于邮件链接下载的随机 token)
- 时间:`created_at`、`updated_at`、`deleted_at`
请求
发票ID
推荐 SQL(MySQL):
```sql
CREATE TABLE `invoices` (
`id` varchar(36) NOT NULL,
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
返回
发票表所有字段
关联订单号
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
`title` varchar(200) NOT NULL COMMENT '发票抬头',
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
`content` varchar(200) NOT NULL COMMENT '开票内容',
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
### 客户发票汇总信息
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
返回
全部发票数
待处理发票数
已开票数
已驳回数
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
### 管理员发票列表
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
请求
search 发票号或订单号模糊匹配
开票状态
申请时间(某日)
分页
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
返回
发票ID
发票申请号
用户名
订单号
发票金额
开票完成时间
开票状态
申请时间
套餐类型 订单的package_name
PRIMARY KEY (`id`),
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
KEY `idx_invoices_customer_id` (`customer_id`),
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
KEY `idx_invoices_status` (`status`),
KEY `idx_invoices_created_at` (`created_at`),
KEY `idx_invoices_deleted_at` (`deleted_at`),
KEY `idx_invoices_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### 管理员发票详情
返回
发票表所有字段
关联订单号
申请人电话
申请人姓名
套餐类型 订单的package_name
开票人full_name
## 6. 接口设计
### 管理员发票汇总信息
返回
全部发票数
待处理发票数
已开票数
已驳回数
### 6.1 通用约定(与项目一致)
- 鉴权:客户端 `CustomerAuth()`;管理端 `AuthMiddleware()`(是否叠加 `AdminOnlyMiddleware()` 视权限策略)
- 返回:成功 `models.APIResponse`,失败 `models.ErrorResponse`
- 多语言:`Accept-Language` 或 `?lang=...`
### 6.2 客户端(/api/cu)
### 管理员发票驳回
#### 6.2.1 客户申请发票
```http
POST /api/cu/invoices
```
请求 Body:
```json
{
"order_id": "order-uuid",
"invoice_type": "enterprise",
"receiver_email": "finance@example.com",
"title": "某某公司",
"taxpayer_id": "9133xxxxxxxxxxxxxx",
"content": "软件服务费",
"remark": "请开电子发票"
}
```
规则(建议):
- `order_id` 必填,且订单必须属于当前用户(`cu_orders.cu_user_id`)
- 仅允许已支付订单开票:`cu_orders.status=paid`
- 一个订单仅允许一张发票:若已存在发票则返回冲突错误
- `invoice_type` 影响 `taxpayer_id` 是否必填(见 3.2)
请求
发票ID
驳回原因
建议修改内容
成功响应:
```json
{
"code": "000000",
"message": "成功",
"data": { "id": "invoice-uuid", "invoice_no": "INV202601210123456789", "status": "pending" },
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 6.2.2 客户发票列表
```http
GET /api/cu/invoices
```
Query:
- `page`(默认 1)
- `page_size`(默认 10,最大 100)
- `status`(可选):`pending/issued/rejected`
- `search`(可选):`invoice_no` 或 `order_no` 模糊匹配
- `apply_date`(可选):申请日期(`YYYY-MM-DD`,按 `created_at` 过滤)
### 管理员发票开票(支持覆盖重开)
#### 6.2.3 客户发票详情
```http
GET /api/cu/invoices/{id}
```
返回:发票表全部字段 + 关联订单号/套餐类型(可 join `cu_orders` 返回 `order_no/package_name`)。
请求
发票ID
发票文件url
开票完成时间
#### 6.2.4 客户发票汇总信息
```http
GET /api/cu/invoices/summary
```
返回:
- `total_count`
- `pending_count`
- `issued_count`
- `rejected_count`
## 相关表结构
#### 6.2.5 发票下载(推荐:鉴权下载)
```http
GET /api/cu/invoices/{id}/download
```
仅允许下载自己的发票;返回 PDF 文件流。
### 6.3 管理端(/api/v1)
#### 6.3.1 管理员发票列表
```http
GET /api/v1/invoices
```
Query 与客户端一致,额外可支持 `customer_id` 筛选。
#### 6.3.2 管理员发票详情
```http
GET /api/v1/invoices/{id}
```
返回:
- 发票表所有字段
- 关联订单号、套餐类型(`cu_orders.package_name`)
- 申请人姓名/电话(`cu_users.real_name/phone`)
- 开票人 `full_name`(通过 `users.full_name`,关联 `uploaded_by`)
#### 6.3.3 管理员发票汇总信息
```http
GET /api/v1/invoices/summary
```
#### 6.3.4 管理员发票驳回
```http
POST /api/v1/invoices/{id}/reject
```
Body:
```json
{ "reject_reason": "抬头信息不完整", "suggestion": "请补充纳税人识别号" }
```
规则:仅允许对 `pending` 发票操作,写入驳回字段并更新 `status=rejected`。
#### 6.3.5 管理员发票开票(支持覆盖重开)
```http
POST /api/v1/invoices/{id}/issue
```
Body:
```json
{ "invoice_file_url": "/files/invoices/INV202601210123456789_20260121103000.pdf", "issued_at": "2026-01-21T10:30:00Z" }
```
规则:
- 允许从 `pending/rejected` 置为 `issued`
- 允许在 `issued` 状态下覆盖更新 `invoice_file_url/issued_at`(“重开覆盖”)
#### 6.3.6 管理端发票文件上传
```http
POST /api/v1/invoices/upload
```
说明:
- 存储目录:程序运行目录相对路径 `../files/invoices/`
- 文件名:`{invoice_no}_{yyyyMMddHHmmss}.pdf`
- 返回相对 URL:`/files/invoices/...pdf`
FormData:
- `invoice_no`
- `file`(pdf)
### 6.4 公共下载接口(可选)
若需要邮件直达下载(不登录),建议采用 token 方式(不要直接暴露任意 `file_url`):
```http
GET /api/public/invoices/download?token=xxxxx
```
规则:
- `token` 对应 `invoices.download_token`(随机生成)
- 仅当 `status=issued` 且存在 `invoice_file_url` 才允许下载
## 7. 错误码规划(建议)
建议新增发票模块错误码段:`70xxxx`
- `700001` 发票不存在
- `700002` 订单不允许开票(非 `paid`)
- `700003` 该订单已存在发票
- `700004` 发票状态不允许操作(如非 `pending` 不能驳回)
- `700005` 发票文件缺失/不可下载
## 8. 相关表结构(现有)
### 附录:现有表结构(参考)
-- license_manager.users definition
+6 -2
View File
@@ -1,5 +1,9 @@
# 提示词
## 接口设计提示词
你需要像一个经验丰富的开发工程师和架构师一样
如果发现架构有问题不合理,应该及时和请求者讨论。
如果要设计接口,请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
开发接口也一样,要学会参考,按照项目本身的规范做而不是独辟蹊径。
有任何无法决策的地方应该主动问请求者而不是随便决策。
请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
接口的请求和响应结构体需要加上中文注释,避免前端看swagger文档还要猜测字段
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -3,6 +3,10 @@ module license-manager
go 1.23
require (
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0
github.com/alibabacloud-go/tea v1.4.0
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.4.0
@@ -11,7 +15,6 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/smartwalle/alipay/v3 v3.2.28
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.3
@@ -27,11 +30,7 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
@@ -39,7 +38,6 @@ require (
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
@@ -69,7 +67,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+9
View File
@@ -8,16 +8,22 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
@@ -25,7 +31,9 @@ github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 h1:rYyjylSQ0xdA2AaYIFky9CSyKDOZpEHdAsIMAcbSIjw=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0/go.mod h1:6ax2auNlAXYhdIqYOTpXWm7vS9AwaWigZFzHKkDm9k8=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
@@ -37,6 +45,7 @@ github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZL
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA=
github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
@@ -0,0 +1,197 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type CuInvoiceHandler struct {
invoiceService service.CuInvoiceService
}
func NewCuInvoiceHandler(invoiceService service.CuInvoiceService) *CuInvoiceHandler {
return &CuInvoiceHandler{
invoiceService: invoiceService,
}
}
// CreateInvoice 客户申请发票
// @Summary 客户申请发票
// @Description 客户为已支付的订单申请发票
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param request body models.InvoiceCreateRequest true "发票申请请求"
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [post]
func (h *CuInvoiceHandler) CreateInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.CreateInvoice(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
response := invoice.ToResponse()
// 只返回关键信息
result := map[string]interface{}{
"id": response.ID,
"invoice_no": response.InvoiceNo,
"status": response.Status,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoices 客户发票列表
// @Summary 客户发票列表
// @Description 获取当前客户的发票列表
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [get]
func (h *CuInvoiceHandler) GetUserInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetUserInvoices(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceDetail 客户发票详情
// @Summary 客户发票详情
// @Description 获取指定发票的详细信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id} [get]
func (h *CuInvoiceHandler) GetUserInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
invoice, err := h.invoiceService.GetInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceSummary 客户发票汇总信息
// @Summary 客户发票汇总信息
// @Description 获取当前客户的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/summary [get]
func (h *CuInvoiceHandler) GetUserInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetInvoiceSummary(c.Request.Context(), getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadInvoice 客户发票下载
// @Summary 客户发票下载
// @Description 下载指定发票的文件
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Param id path string true "发票ID"
// @Success 200 {file} binary "发票文件"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在或文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id}/download [get]
func (h *CuInvoiceHandler) DownloadInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
fileURL, err := h.invoiceService.DownloadInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
@@ -0,0 +1,372 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type AdminInvoiceHandler struct {
invoiceService service.AdminInvoiceService
}
func NewAdminInvoiceHandler(invoiceService service.AdminInvoiceService) *AdminInvoiceHandler {
return &AdminInvoiceHandler{
invoiceService: invoiceService,
}
}
// GetAdminInvoices 管理员发票列表
// @Summary 管理员发票列表
// @Description 管理员查看所有发票列表,支持按客户筛选
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD"
// @Param customer_id query string false "客户ID筛选"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices [get]
func (h *AdminInvoiceHandler) GetAdminInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetAdminInvoices(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceDetail 管理员发票详情
// @Summary 管理员发票详情
// @Description 管理员查看发票详细信息,包含关联信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceDetailResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id} [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
detail, err := h.invoiceService.GetInvoiceDetail(c.Request.Context(), invoiceID, true, adminID)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: detail,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceSummary 管理员发票汇总信息
// @Summary 管理员发票汇总信息
// @Description 管理员查看全平台的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/summary [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetAdminInvoiceSummary(c.Request.Context())
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// RejectInvoice 管理员发票驳回
// @Summary 管理员发票驳回
// @Description 管理员驳回发票申请,需要填写驳回原因和修改建议
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param id path string true "发票ID"
// @Param request body models.InvoiceRejectRequest true "驳回请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/reject [post]
func (h *AdminInvoiceHandler) RejectInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.RejectInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// IssueInvoice 管理员发票开票
// @Summary 管理员发票开票
// @Description 管理员为发票开票,上传发票文件并设置开票时间
// @Tags 发票管理
// @Accept json
// @Produce json
// @Param id path string true "发票ID"
// @Param request body models.InvoiceIssueRequest true "开票请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/issue [post]
func (h *AdminInvoiceHandler) IssueInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceIssueRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.IssueInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UploadInvoiceFile 管理端发票文件上传
// @Summary 管理端发票文件上传
// @Description 管理员上传发票PDF文件
// @Tags 发票管理
// @Accept multipart/form-data
// @Produce json
// @Param invoice_no formData string true "发票申请号"
// @Param file formData file true "发票PDF文件"
// @Success 200 {object} models.APIResponse{data=models.InvoiceUploadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/upload [post]
func (h *AdminInvoiceHandler) UploadInvoiceFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
// 获取表单参数
invoiceNo := c.PostForm("invoice_no")
if invoiceNo == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
defer file.Close()
// 验证文件类型
if filepath.Ext(header.Filename) != ".pdf" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
// 构建文件保存路径
uploadDir := filepath.Join("..", "files", "invoices")
filename := invoiceNo + "_" + strconv.FormatInt(time.Now().Unix(), 10) + ".pdf"
filePath := filepath.Join(uploadDir, filename)
// 保存文件
if err := c.SaveUploadedFile(header, filePath); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
// 返回相对URL
fileURL := "/files/invoices/" + filename
response := models.InvoiceUploadResponse{
FileURL: fileURL,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: response,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadByToken 公共发票下载(通过token)
// @Summary 公共发票下载
// @Description 通过下载token下载发票文件,无需登录验证
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Param token query string true "下载token"
// @Success 200 {file} binary "发票文件"
// @Failure 400 {object} models.ErrorResponse "token无效"
// @Failure 404 {object} models.ErrorResponse "文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/public/invoices/download [get]
func (h *AdminInvoiceHandler) DownloadByToken(c *gin.Context) {
lang := middleware.GetLanguage(c)
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
fileURL, err := h.invoiceService.DownloadByToken(c.Request.Context(), token)
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
// 辅助函数
// getCuUserID 获取客户用户ID
func getCuUserID(c *gin.Context) string {
if userID, exists := c.Get("cu_user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// getUserID 获取管理员用户ID
func getUserID(c *gin.Context) string {
if userID, exists := c.Get("user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// handleI18nError 处理国际化错误
func handleI18nError(c *gin.Context, err error, lang string) {
if i18nErr, ok := err.(*i18n.I18nError); ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 默认错误处理
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang),
Timestamp: getCurrentTimestamp(),
})
}
+24
View File
@@ -47,6 +47,8 @@ func SetupRouter() *gin.Engine {
licenseRepo := repository.NewLicenseRepository(db)
dashboardRepo := repository.NewDashboardRepository(db)
paymentRepo := repository.NewPaymentRepository(db)
cuInvoiceRepo := repository.NewCuInvoiceRepository(db)
adminInvoiceRepo := repository.NewAdminInvoiceRepository(db)
// 获取logger实例
log := logger.GetLogger()
@@ -105,10 +107,14 @@ func SetupRouter() *gin.Engine {
cuDeviceHandler := handlers.NewCuDeviceHandler(cuDeviceService)
paymentHandler := handlers.NewPaymentHandler(paymentService)
cuAuthorizationHandler := handlers.NewCuAuthorizationHandler(authCodeService)
cuInvoiceService := service.NewCuInvoiceService(cuInvoiceRepo, cuOrderRepo, db)
cuInvoiceHandler := handlers.NewCuInvoiceHandler(cuInvoiceService)
enumHandler := handlers.NewEnumHandler(enumService)
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
licenseHandler := handlers.NewLicenseHandler(licenseService)
dashboardHandler := handlers.NewDashboardHandler(dashboardService)
adminInvoiceService := service.NewAdminInvoiceService(adminInvoiceRepo, cuOrderRepo, userRepo, cuUserRepo, db)
adminInvoiceHandler := handlers.NewAdminInvoiceHandler(adminInvoiceService)
// 健康检测接口(无需认证)
router.GET("/health", systemHandler.HealthCheck)
@@ -131,6 +137,9 @@ func SetupRouter() *gin.Engine {
// 支付回调接口
public.POST("/payment/alipay/callback", paymentHandler.AlipayCallback)
public.POST("/v1/payment/alipay/callback", paymentHandler.AlipayCallback)
// 公共发票下载接口
public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken)
}
// 需要认证的接口
@@ -176,6 +185,14 @@ func SetupRouter() *gin.Engine {
// 仪表盘接口
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
// 发票管理(管理员)
auth.GET("/v1/invoices", adminInvoiceHandler.GetAdminInvoices)
auth.GET("/v1/invoices/:id", adminInvoiceHandler.GetAdminInvoiceDetail)
auth.GET("/v1/invoices/summary", adminInvoiceHandler.GetAdminInvoiceSummary)
auth.POST("/v1/invoices/:id/reject", adminInvoiceHandler.RejectInvoice)
auth.POST("/v1/invoices/:id/issue", adminInvoiceHandler.IssueInvoice)
auth.POST("/v1/invoices/upload", adminInvoiceHandler.UploadInvoiceFile)
}
// 管理员接口
@@ -235,6 +252,13 @@ func SetupRouter() *gin.Engine {
cuAuth.GET("/devices", cuDeviceHandler.GetDevices)
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
cuAuth.DELETE("/devices/:id", cuDeviceHandler.UnbindDevice)
// 发票管理
cuAuth.POST("/invoices", cuInvoiceHandler.CreateInvoice)
cuAuth.GET("/invoices", cuInvoiceHandler.GetUserInvoices)
cuAuth.GET("/invoices/:id", cuInvoiceHandler.GetUserInvoiceDetail)
cuAuth.GET("/invoices/summary", cuInvoiceHandler.GetUserInvoiceSummary)
cuAuth.GET("/invoices/:id/download", cuInvoiceHandler.DownloadInvoice)
}
}
+1
View File
@@ -29,6 +29,7 @@ func AutoMigrate() error {
&models.AuthorizationCode{},
&models.License{},
&models.AuthorizationChange{},
&models.Invoice{}, // 发票表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
@@ -0,0 +1,35 @@
package models
// ========== 管理端发票请求/响应结构体 ==========
// InvoiceRejectRequest 发票驳回请求结构(管理端)
type InvoiceRejectRequest struct {
RejectReason string `json:"reject_reason" binding:"required,max=500"` // 驳回原因,必填,最大500字符
Suggestion string `json:"suggestion" binding:"required,max=500"` // 修改建议,必填,最大500字符
}
// InvoiceIssueRequest 发票开票请求结构(管理端)
type InvoiceIssueRequest struct {
InvoiceFileURL string `json:"invoice_file_url" binding:"required,max=500"` // 发票文件URL,必填,最大500字符
IssuedAt string `json:"issued_at" binding:"required"` // 开票时间,必填,ISO 8601格式时间字符串
}
// InvoiceUploadRequest 发票文件上传请求结构(管理端)
type InvoiceUploadRequest struct {
InvoiceNo string `form:"invoice_no" binding:"required"` // 发票申请号,必填
}
// InvoiceUploadResponse 发票文件上传响应结构(管理端)
type InvoiceUploadResponse struct {
FileURL string `json:"file_url"` // 上传后的文件URL
}
// InvoiceDetailResponse 发票详情响应结构(包含关联信息,管理端使用)
type InvoiceDetailResponse struct {
*InvoiceResponse
OrderPackageName string `json:"order_package_name,omitempty"` // 关联订单的套餐名称
ApplicantName string `json:"applicant_name,omitempty"` // 发票申请人真实姓名
ApplicantPhone string `json:"applicant_phone,omitempty"` // 发票申请人电话号码
UploaderName string `json:"uploader_name,omitempty"` // 发票文件上传人姓名(管理员)
RejecterName string `json:"rejecter_name,omitempty"` // 发票驳回人姓名(管理员)
}
@@ -0,0 +1,40 @@
package models
// ========== 客户端发票请求/响应结构体 ==========
// InvoiceCreateRequest 客户申请发票请求结构
type InvoiceCreateRequest struct {
OrderID string `json:"order_id" binding:"required"` // 订单ID,必填
InvoiceType string `json:"invoice_type" binding:"required,oneof=personal enterprise vat_special"` // 发票类型,必填,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
ReceiverEmail string `json:"receiver_email" binding:"required,email"` // 收票邮箱,必填,格式为邮箱地址
Title string `json:"title" binding:"required,max=200"` // 发票抬头,必填,最大200字符
TaxpayerID *string `json:"taxpayer_id,omitempty"` // 纳税人识别号,企业发票必填,个人发票可为空
Content string `json:"content" binding:"required,max=200"` // 开票内容,必填,最大200字符
Remark *string `json:"remark,omitempty"` // 备注,可选
}
// InvoiceListRequest 发票列表查询请求结构
type InvoiceListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认10,最大100
Status string `form:"status" binding:"omitempty"` // 状态筛选,pending-待处理/issued-已开票/rejected-已驳回
Search string `form:"search" binding:"omitempty"` // 搜索关键词,支持发票号或订单号模糊匹配
ApplyDate string `form:"apply_date" binding:"omitempty"` // 申请日期筛选,格式YYYY-MM-DD
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选(管理端)
}
// InvoiceListResponse 发票列表响应结构
type InvoiceListResponse struct {
Invoices []*InvoiceResponse `json:"invoices"` // 发票列表
TotalCount int64 `json:"total_count"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
}
// InvoiceSummaryResponse 发票汇总统计响应结构
type InvoiceSummaryResponse struct {
TotalCount int64 `json:"total_count"` // 发票总数
PendingCount int64 `json:"pending_count"` // 待处理发票数量
IssuedCount int64 `json:"issued_count"` // 已开票发票数量
RejectedCount int64 `json:"rejected_count"` // 已驳回发票数量
}
+15 -15
View File
@@ -75,21 +75,21 @@ type CuOrderListRequest struct {
// CuOrderResponse 订单响应结构
type CuOrderResponse struct {
ID string `json:"id"`
OrderNo string `json:"order_no"`
CustomerID string `json:"customer_id"`
CuUserID string `json:"cu_user_id"`
PackageID string `json:"package_id"`
PackageName string `json:"package_name"`
LicenseCount int `json:"license_count"`
UnitPrice float64 `json:"unit_price"`
DiscountRate float64 `json:"discount_rate"`
TotalAmount float64 `json:"total_amount"`
Status string `json:"status"`
AuthorizationCode *string `json:"authorization_code"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"` // 订单ID
OrderNo string `json:"order_no"` // 订单号,格式如ORD202601210123456789
CustomerID string `json:"customer_id"` // 客户ID
CuUserID string `json:"cu_user_id"` // 客户用户ID
PackageID string `json:"package_id"` // 套餐ID
PackageName string `json:"package_name"` // 套餐名称
LicenseCount int `json:"license_count"` // 许可数量
UnitPrice float64 `json:"unit_price"` // 单价,单位元
DiscountRate float64 `json:"discount_rate"` // 折扣率,0.0-1.0之间
TotalAmount float64 `json:"total_amount"` // 订单总金额,单位元
Status string `json:"status"` // 订单状态,pending-待支付/paid-已支付
AuthorizationCode *string `json:"authorization_code"` // 授权码,已支付订单生成
ExpiredAt *time.Time `json:"expired_at"` // 订单过期时间
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// ToResponse 转换为响应结构
+57 -56
View File
@@ -75,55 +75,56 @@ func (u *CuUser) IsAccountLocked() bool {
return false
}
// CuUserRegisterRequest 客户用户注册请求结构
// CuUserRegisterRequest 客户用户注册请求结构
type CuUserRegisterRequest struct {
Phone string `json:"phone" binding:"required,len=11"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
CustomerID string `json:"customer_id" binding:"omitempty"` // 可选,不提供则自动创建客户
Password string `json:"password" binding:"required,min=8,max=50"`
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
Phone string `json:"phone" binding:"required,len=11"` // 手机号,必填,11位手机号
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
CustomerID string `json:"customer_id" binding:"omitempty"` // 客户ID,可选,不提供则自动创建客户
Password string `json:"password" binding:"required,min=8,max=50"` // 密码,必填,8-50位
RealName *string `json:"real_name" binding:"omitempty,max=100"` // 真实姓名,可选,最大100字符
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
}
// CuUserLoginRequest 客户用户登录请求结构
type CuUserLoginRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Password string `json:"password"` // 密码登录时必填
SmsCode string `json:"sms_code"` // 验证码登录时必填
LoginType string `json:"login_type" binding:"required,oneof=password sms"` // 登录类型:password 或 sms
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
Password string `json:"password"` // 密码,密码登录时必填
SmsCode string `json:"sms_code"` // 短信验证码,验证码登录时必填
LoginType string `json:"login_type" binding:"required,oneof=password sms"` // 登录类型,必填,password-密码登录/sms-验证码登录
}
// CuUserProfileUpdateRequest 客户用户资料更新请求结构
type CuUserProfileUpdateRequest struct {
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"`
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"`
Timezone *string `json:"timezone" binding:"omitempty,max=50"`
AdditionalInfo *string `json:"additional_info" binding:"omitempty"`
Remark *string `json:"remark" binding:"omitempty,max=1000"`
RealName *string `json:"real_name" binding:"omitempty,max=100"` // 真实姓名,可选,最大100字符
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"` // 头像URL,可选,最大500字符
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"` // 语言偏好,可选,zh-CN-中文/en-US-英文/ja-JP-日文
Timezone *string `json:"timezone" binding:"omitempty,max=50"` // 时区,可选,最大50字符
AdditionalInfo *string `json:"additional_info" binding:"omitempty"` // 附加信息,可选,JSON格式
Remark *string `json:"remark" binding:"omitempty,max=1000"` // 备注,可选,最大1000字符
}
// CuUserPhoneUpdateRequest 客户用户手机号更新请求结构
type CuUserPhoneUpdateRequest struct {
NewPhone string `json:"new_phone" binding:"required,len=11"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"`
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码
NewPhone string `json:"new_phone" binding:"required,len=11"` // 新手机号,必填,11位手机号
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"` // 新手机号国家区号,必填
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码,必填,6位数字
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码,必填,6位数字
}
// CuUserSendRegisterSmsRequest 注册发送验证码请求结构
type CuUserSendRegisterSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserSendLoginSmsRequest 发送登录验证码请求结构
type CuUserSendLoginSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserSendCurrentPhoneSmsRequest 发送当前手机号验证码请求结构
@@ -133,51 +134,51 @@ type CuUserSendCurrentPhoneSmsRequest struct {
// CuUserSendNewPhoneSmsRequest 发送新手机号验证码请求结构
type CuUserSendNewPhoneSmsRequest struct {
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 可选,默认+86
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"` // 新手机号,必填,7-20位
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 新手机号国家区号,可选,默认+86
}
// CuUserForgotPasswordRequest 忘记密码请求结构(发送重置验证码)
type CuUserForgotPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserResetPasswordRequest 重置密码请求结构
type CuUserResetPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
}
// CuUserChangePasswordRequest 修改密码请求结构
type CuUserChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
OldPassword string `json:"old_password" binding:"required"` // 旧密码,必填
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
}
// CuUserResponse 用户信息响应结构(用于API,避免暴露敏感字段)
type CuUserResponse struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Phone string `json:"phone"`
PhoneCountryCode string `json:"phone_country_code"`
UserRole string `json:"user_role"`
RealName *string `json:"real_name"`
Email *string `json:"email"`
Status string `json:"status"`
PhoneVerified bool `json:"phone_verified"`
EmailVerified bool `json:"email_verified"`
LastLoginAt *string `json:"last_login_at,omitempty"`
LastLoginIP *string `json:"last_login_ip,omitempty"`
AvatarURL *string `json:"avatar_url"`
Language string `json:"language"`
Timezone string `json:"timezone"`
AdditionalInfo *string `json:"additional_info"`
Remark *string `json:"remark"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"` // 用户ID
CustomerID string `json:"customer_id"` // 客户ID
Phone string `json:"phone"` // 手机号
PhoneCountryCode string `json:"phone_country_code"` // 国家区号
UserRole string `json:"user_role"` // 用户角色,admin-管理员/member-成员
RealName *string `json:"real_name"` // 真实姓名
Email *string `json:"email"` // 邮箱
Status string `json:"status"` // 账户状态,active-激活/disabled-禁用
PhoneVerified bool `json:"phone_verified"` // 手机号是否已验证
EmailVerified bool `json:"email_verified"` // 邮箱是否已验证
LastLoginAt *string `json:"last_login_at,omitempty"` // 最后登录时间
LastLoginIP *string `json:"last_login_ip,omitempty"` // 最后登录IP
AvatarURL *string `json:"avatar_url"` // 头像URL
Language string `json:"language"` // 语言偏好
Timezone string `json:"timezone"` // 时区
AdditionalInfo *string `json:"additional_info"` // 附加信息,JSON格式
Remark *string `json:"remark"` // 备注
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// ToResponse 转换为响应结构
+13 -13
View File
@@ -109,27 +109,27 @@ func (p *Payment) ToResponse() *PaymentResponse {
// PaymentStatusResponse 支付状态响应结构
type PaymentStatusResponse struct {
PaymentNo string `json:"payment_no"`
Status string `json:"status"`
Amount float64 `json:"amount"`
PaymentTime *time.Time `json:"payment_time"`
TradeNo *string `json:"trade_no"`
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"`
PaymentNo string `json:"payment_no"` // 支付单号
Status string `json:"status"` // 支付状态,pending-待支付/paid-已支付/cancelled-已取消/expired-已过期/failed-支付失败
Amount float64 `json:"amount"` // 支付金额,单位元
PaymentTime *time.Time `json:"payment_time"` // 支付完成时间
TradeNo *string `json:"trade_no"` // 第三方交易号
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"` // 关联的业务订单信息
}
// PaymentBusinessOrder 支付关联的业务订单信息
type PaymentBusinessOrder struct {
OrderNo string `json:"order_no"`
AuthorizationCode *string `json:"authorization_code"`
Status string `json:"status"`
OrderNo string `json:"order_no"` // 订单号
AuthorizationCode *string `json:"authorization_code"` // 生成的授权码
Status string `json:"status"` // 订单状态
}
// PaymentListResponse 支付列表响应结构
type PaymentListResponse struct {
Payments []*PaymentResponse `json:"payments"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Payments []*PaymentResponse `json:"payments"` // 支付记录列表
TotalCount int64 `json:"total_count"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
}
// 业务类型常量
@@ -0,0 +1,150 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ========== 共享发票数据模型 ==========
// Invoice 发票模型
type Invoice struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
InvoiceNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"invoice_no"`
OrderID string `gorm:"type:varchar(36);not null" json:"order_id"`
OrderNo string `gorm:"type:varchar(50);not null;index" json:"order_no"`
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"`
CuUserID string `gorm:"type:varchar(36);not null;index" json:"cu_user_id"`
Amount float64 `gorm:"type:decimal(10,2);not null" json:"amount"`
Status string `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"`
InvoiceType string `gorm:"type:varchar(20);not null" json:"invoice_type"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
TaxpayerID *string `gorm:"type:varchar(50)" json:"taxpayer_id"`
Content string `gorm:"type:varchar(200);not null" json:"content"`
ReceiverEmail string `gorm:"type:varchar(255);not null" json:"receiver_email"`
Remark *string `gorm:"type:varchar(1000)" json:"remark"`
InvoiceFileURL *string `gorm:"type:varchar(500)" json:"invoice_file_url"`
UploadedAt *time.Time `gorm:"" json:"uploaded_at"`
UploadedBy *string `gorm:"type:varchar(36)" json:"uploaded_by"`
IssuedAt *time.Time `gorm:"" json:"issued_at"`
RejectReason *string `gorm:"type:varchar(500)" json:"reject_reason"`
Suggestion *string `gorm:"type:varchar(500)" json:"suggestion"`
RejectedAt *time.Time `gorm:"" json:"rejected_at"`
RejectedBy *string `gorm:"type:varchar(36)" json:"rejected_by"`
DownloadToken *string `gorm:"type:varchar(64)" json:"download_token"`
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName 指定表名
func (Invoice) TableName() string {
return "invoices"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (i *Invoice) BeforeCreate(tx *gorm.DB) error {
// 生成UUID作为主键ID
if i.ID == "" {
i.ID = uuid.New().String()
}
// 设置时间戳
now := time.Now()
if i.CreatedAt.IsZero() {
i.CreatedAt = now
}
if i.UpdatedAt.IsZero() {
i.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动设置时间戳
func (i *Invoice) BeforeUpdate(tx *gorm.DB) error {
i.UpdatedAt = time.Now()
return nil
}
// ========== 发票枚举常量 ==========
// 发票状态枚举
const (
InvoiceStatusPending = "pending" // 待处理
InvoiceStatusIssued = "issued" // 已开票
InvoiceStatusRejected = "rejected" // 已驳回
)
// 发票类型枚举
const (
InvoiceTypePersonal = "personal" // 个人
InvoiceTypeEnterprise = "enterprise" // 企业普票
InvoiceTypeVATSpecial = "vat_special" // 增值税专用发票
)
// ========== 共享响应结构体 ==========
// InvoiceResponse 发票响应结构
type InvoiceResponse struct {
ID string `json:"id"` // 发票ID
InvoiceNo string `json:"invoice_no"` // 发票申请号,格式如INV202601210123456789
OrderID string `json:"order_id"` // 关联的订单ID
OrderNo string `json:"order_no"` // 关联的订单号
CustomerID string `json:"customer_id"` // 客户ID
CuUserID string `json:"cu_user_id"` // 申请人客户用户ID
Amount float64 `json:"amount"` // 发票金额,单位元,保留2位小数
Status string `json:"status"` // 发票状态,pending-待处理/issued-已开票/rejected-已驳回
StatusDisplay string `json:"status_display"` // 发票状态显示文本,根据当前语言显示
InvoiceType string `json:"invoice_type"` // 发票类型,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
InvoiceTypeDisplay string `json:"invoice_type_display"` // 发票类型显示文本,根据当前语言显示
Title string `json:"title"` // 发票抬头
TaxpayerID *string `json:"taxpayer_id"` // 纳税人识别号,企业发票填写
Content string `json:"content"` // 开票内容
ReceiverEmail string `json:"receiver_email"` // 收票邮箱
Remark *string `json:"remark"` // 备注信息
InvoiceFileURL *string `json:"invoice_file_url"` // 发票文件下载URL,已开票后填写
UploadedAt *time.Time `json:"uploaded_at"` // 发票文件上传时间
UploadedBy *string `json:"uploaded_by"` // 发票文件上传人ID(管理员)
IssuedAt *time.Time `json:"issued_at"` // 发票开票完成时间
RejectReason *string `json:"reject_reason"` // 驳回原因,已驳回时填写
Suggestion *string `json:"suggestion"` // 修改建议,已驳回时填写
RejectedAt *time.Time `json:"rejected_at"` // 驳回时间
RejectedBy *string `json:"rejected_by"` // 驳回人ID(管理员)
DownloadToken *string `json:"download_token"` // 下载令牌,用于邮件链接下载
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// ToResponse 转换为响应结构(不包含多语言显示字段)
func (i *Invoice) ToResponse() *InvoiceResponse {
return &InvoiceResponse{
ID: i.ID,
InvoiceNo: i.InvoiceNo,
OrderID: i.OrderID,
OrderNo: i.OrderNo,
CustomerID: i.CustomerID,
CuUserID: i.CuUserID,
Amount: i.Amount,
Status: i.Status,
InvoiceType: i.InvoiceType,
Title: i.Title,
TaxpayerID: i.TaxpayerID,
Content: i.Content,
ReceiverEmail: i.ReceiverEmail,
Remark: i.Remark,
InvoiceFileURL: i.InvoiceFileURL,
UploadedAt: i.UploadedAt,
UploadedBy: i.UploadedBy,
IssuedAt: i.IssuedAt,
RejectReason: i.RejectReason,
Suggestion: i.Suggestion,
RejectedAt: i.RejectedAt,
RejectedBy: i.RejectedBy,
DownloadToken: i.DownloadToken,
CreatedAt: i.CreatedAt,
UpdatedAt: i.UpdatedAt,
}
}
@@ -0,0 +1,207 @@
package repository
import (
"context"
"license-manager/internal/models"
"strings"
"time"
"gorm.io/gorm"
)
// AdminInvoiceRepository 管理端发票仓储接口
type AdminInvoiceRepository interface {
// ========== 管理端专用方法 ==========
GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error)
GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
// ========== 通用工具方法 ==========
GetByID(id string) (*models.Invoice, error)
GetByInvoiceNo(invoiceNo string) (*models.Invoice, error)
GetByOrderID(orderID string) (*models.Invoice, error)
Update(invoice *models.Invoice) error
Delete(id string) error
GetByDownloadToken(token string) (*models.Invoice, error)
UpdateDownloadToken(id string, token *string) error
}
type adminInvoiceRepository struct {
db *gorm.DB
}
func NewAdminInvoiceRepository(db *gorm.DB) AdminInvoiceRepository {
return &adminInvoiceRepository{db: db}
}
func (r *adminInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByInvoiceNo(invoiceNo string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("invoice_no = ? AND deleted_at IS NULL", invoiceNo).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByOrderID(orderID string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("order_id = ? AND deleted_at IS NULL", orderID).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
query := r.db.Model(&models.Invoice{}).Where("customer_id = ? AND deleted_at IS NULL", customerID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *adminInvoiceRepository) GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
customerID := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = strings.TrimSpace(req.Search)
status = strings.TrimSpace(req.Status)
customerID = strings.TrimSpace(req.CustomerID)
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("deleted_at IS NULL")
if status != "" {
query = query.Where("status = ?", status)
}
if customerID != "" {
query = query.Where("customer_id = ?", customerID)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
}
if createdAtStart != nil && createdAtEnd != nil {
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *adminInvoiceRepository) Update(invoice *models.Invoice) error {
return r.db.Save(invoice).Error
}
func (r *adminInvoiceRepository) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&models.Invoice{}).Error
}
func (r *adminInvoiceRepository) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
// 查询发票总数
var totalCount int64
err := r.db.Model(&models.Invoice{}).
Where("deleted_at IS NULL").
Count(&totalCount).Error
if err != nil {
return nil, err
}
// 查询待处理发票数
var pendingCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusPending).
Count(&pendingCount).Error
if err != nil {
return nil, err
}
// 查询已开票数
var issuedCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusIssued).
Count(&issuedCount).Error
if err != nil {
return nil, err
}
// 查询已驳回数
var rejectedCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusRejected).
Count(&rejectedCount).Error
if err != nil {
return nil, err
}
return &models.InvoiceSummaryResponse{
TotalCount: totalCount,
PendingCount: pendingCount,
IssuedCount: issuedCount,
RejectedCount: rejectedCount,
}, nil
}
func (r *adminInvoiceRepository) GetByDownloadToken(token string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("download_token = ? AND deleted_at IS NULL", token).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) UpdateDownloadToken(id string, token *string) error {
return r.db.Model(&models.Invoice{}).Where("id = ?", id).Update("download_token", token).Error
}
@@ -0,0 +1,149 @@
package repository
import (
"context"
"license-manager/internal/models"
"strings"
"time"
"gorm.io/gorm"
)
// CuInvoiceRepository 客户端发票仓储接口
type CuInvoiceRepository interface {
// ========== 客户端专用方法 ==========
GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
// ========== 通用工具方法 ==========
Create(invoice *models.Invoice) error
GetByID(id string) (*models.Invoice, error)
CheckOrderInvoiceExists(orderID string) (bool, error)
}
type cuInvoiceRepository struct {
db *gorm.DB
}
func NewCuInvoiceRepository(db *gorm.DB) CuInvoiceRepository {
return &cuInvoiceRepository{db: db}
}
func (r *cuInvoiceRepository) Create(invoice *models.Invoice) error {
return r.db.Create(invoice).Error
}
func (r *cuInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *cuInvoiceRepository) GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = strings.TrimSpace(req.Search)
status = strings.TrimSpace(req.Status)
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
if status != "" {
query = query.Where("status = ?", status)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
}
if createdAtStart != nil && createdAtEnd != nil {
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *cuInvoiceRepository) GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
// 查询发票总数
var totalCount int64
err := r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID).
Count(&totalCount).Error
if err != nil {
return nil, err
}
// 查询待处理发票数
var pendingCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusPending).
Count(&pendingCount).Error
if err != nil {
return nil, err
}
// 查询已开票数
var issuedCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusIssued).
Count(&issuedCount).Error
if err != nil {
return nil, err
}
// 查询已驳回数
var rejectedCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusRejected).
Count(&rejectedCount).Error
if err != nil {
return nil, err
}
return &models.InvoiceSummaryResponse{
TotalCount: totalCount,
PendingCount: pendingCount,
IssuedCount: issuedCount,
RejectedCount: rejectedCount,
}, nil
}
func (r *cuInvoiceRepository) CheckOrderInvoiceExists(orderID string) (bool, error) {
var count int64
err := r.db.Model(&models.Invoice{}).
Where("order_id = ? AND deleted_at IS NULL", orderID).
Count(&count).Error
return count > 0, err
}
@@ -0,0 +1,291 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"time"
"gorm.io/gorm"
)
// AdminInvoiceService 管理端发票服务接口
type AdminInvoiceService interface {
// ========== 管理端发票操作 ==========
RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error)
IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error)
GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error)
GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error)
// ========== 公共发票操作 ==========
DownloadByToken(ctx context.Context, token string) (string, error)
}
type adminInvoiceService struct {
repo repository.AdminInvoiceRepository
orderRepo repository.CuOrderRepository
userRepo repository.UserRepository
cuUserRepo repository.CuUserRepository
db *gorm.DB
}
func NewAdminInvoiceService(repo repository.AdminInvoiceRepository, orderRepo repository.CuOrderRepository, userRepo repository.UserRepository, cuUserRepo repository.CuUserRepository, db *gorm.DB) AdminInvoiceService {
return &adminInvoiceService{
repo: repo,
orderRepo: orderRepo,
userRepo: userRepo,
cuUserRepo: cuUserRepo,
db: db,
}
}
// ========== 管理端发票操作实现 ==========
func (s *adminInvoiceService) RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 验证状态
if invoice.Status != models.InvoiceStatusPending {
return nil, i18n.NewI18nError("700004", lang)
}
now := time.Now()
invoice.Status = models.InvoiceStatusRejected
invoice.RejectReason = &req.RejectReason
invoice.Suggestion = &req.Suggestion
invoice.RejectedAt = &now
invoice.RejectedBy = &adminID
invoice.UpdatedAt = now
err = s.repo.Update(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *adminInvoiceService) IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 验证状态:允许从pending或rejected状态开票
if invoice.Status != models.InvoiceStatusPending && invoice.Status != models.InvoiceStatusRejected {
return nil, i18n.NewI18nError("700004", lang)
}
// 解析开票时间
issuedAt, err := time.Parse(time.RFC3339, req.IssuedAt)
if err != nil {
return nil, i18n.NewI18nError("700004", lang)
}
now := time.Now()
invoice.Status = models.InvoiceStatusIssued
invoice.InvoiceFileURL = &req.InvoiceFileURL
invoice.IssuedAt = &issuedAt
invoice.UploadedAt = &now
invoice.UploadedBy = &adminID
invoice.UpdatedAt = now
// 清除驳回信息(如果是从rejected状态开票)
if invoice.Status == models.InvoiceStatusRejected {
invoice.RejectReason = nil
invoice.Suggestion = nil
invoice.RejectedAt = nil
invoice.RejectedBy = nil
}
err = s.repo.Update(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *adminInvoiceService) GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 解析日期筛选
var createdAtStart, createdAtEnd *time.Time
if req.ApplyDate != "" {
start, end, err := s.parseDateRange(req.ApplyDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
createdAtStart = start
createdAtEnd = end
}
invoices, total, err := s.repo.GetAdminList(ctx, req, createdAtStart, createdAtEnd)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应结构并填充多语言显示字段
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
for i, invoice := range invoices {
invoiceResponses[i] = invoice.ToResponse()
s.fillDisplayFields(invoiceResponses[i], lang)
}
return &models.InvoiceListResponse{
Invoices: invoiceResponses,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
func (s *adminInvoiceService) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
return s.repo.GetAdminInvoiceSummary(ctx)
}
func (s *adminInvoiceService) GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 权限验证
if !isAdmin && invoice.CuUserID != userID {
return nil, i18n.NewI18nError("700001", lang)
}
response := &models.InvoiceDetailResponse{
InvoiceResponse: invoice.ToResponse(),
}
// 填充多语言显示字段
s.fillDetailDisplayFields(response, lang)
// 获取关联信息
if isAdmin {
// 管理端需要更多关联信息
order, err := s.orderRepo.GetByID(invoice.OrderID)
if err == nil {
response.OrderPackageName = order.PackageName
}
cuUser, err := s.cuUserRepo.GetByID(invoice.CuUserID)
if err == nil && cuUser.RealName != nil {
response.ApplicantName = *cuUser.RealName
response.ApplicantPhone = cuUser.Phone
}
if invoice.UploadedBy != nil {
user, err := s.userRepo.GetUserByID(ctx, *invoice.UploadedBy)
if err == nil {
response.UploaderName = user.FullName
}
}
if invoice.RejectedBy != nil {
user, err := s.userRepo.GetUserByID(ctx, *invoice.RejectedBy)
if err == nil {
response.RejecterName = user.FullName
}
}
}
return response, nil
}
func (s *adminInvoiceService) GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return "", i18n.NewI18nError("700001", lang)
}
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
// 生成随机token
tokenBytes := make([]byte, 32)
_, err = rand.Read(tokenBytes)
if err != nil {
return "", err
}
token := hex.EncodeToString(tokenBytes)
// 更新token
err = s.repo.UpdateDownloadToken(invoiceID, &token)
if err != nil {
return "", err
}
return token, nil
}
// ========== 公共发票操作实现 ==========
func (s *adminInvoiceService) DownloadByToken(ctx context.Context, token string) (string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByDownloadToken(token)
if err != nil {
return "", i18n.NewI18nError("700001", lang)
}
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
return *invoice.InvoiceFileURL, nil
}
// ========== 私有辅助方法 ==========
// fillDisplayFields 填充响应结构的多语言显示字段
func (s *adminInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// fillDetailDisplayFields 填充详情响应结构的多语言显示字段
func (s *adminInvoiceService) fillDetailDisplayFields(response *models.InvoiceDetailResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// parseDateRange 解析日期范围
func (s *adminInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
// 假设输入格式为 YYYY-MM-DD
start, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, nil, err
}
end := start.AddDate(0, 0, 1) // 下一天
return &start, &end, nil
}
@@ -0,0 +1,204 @@
package service
import (
"context"
"fmt"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"time"
"gorm.io/gorm"
)
// CuInvoiceService 客户端发票服务接口
type CuInvoiceService interface {
// ========== 客户端发票操作 ==========
CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error)
GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error)
GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error)
}
type cuInvoiceService struct {
repo repository.CuInvoiceRepository
orderRepo repository.CuOrderRepository
db *gorm.DB
}
func NewCuInvoiceService(repo repository.CuInvoiceRepository, orderRepo repository.CuOrderRepository, db *gorm.DB) CuInvoiceService {
return &cuInvoiceService{
repo: repo,
orderRepo: orderRepo,
db: db,
}
}
// ========== 客户端发票操作实现 ==========
func (s *cuInvoiceService) CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 验证订单是否存在且属于当前用户
order, err := s.orderRepo.GetByID(req.OrderID)
if err != nil {
return nil, i18n.NewI18nError("700002", lang)
}
if order.CuUserID != cuUserID {
return nil, i18n.NewI18nError("700002", lang)
}
// 验证订单状态必须为已支付
if order.Status != "paid" {
return nil, i18n.NewI18nError("700002", lang)
}
// 检查是否已存在发票
exists, err := s.repo.CheckOrderInvoiceExists(req.OrderID)
if err != nil {
return nil, err
}
if exists {
return nil, i18n.NewI18nError("700003", lang)
}
// 验证发票类型和纳税人识别号
if req.InvoiceType != models.InvoiceTypePersonal && (req.TaxpayerID == nil || *req.TaxpayerID == "") {
return nil, i18n.NewI18nError("700004", lang)
}
// 生成发票申请号
invoiceNo := s.generateInvoiceNo()
// 创建发票记录
now := time.Now()
invoice := &models.Invoice{
InvoiceNo: invoiceNo,
OrderID: req.OrderID,
OrderNo: order.OrderNo,
CustomerID: order.CustomerID,
CuUserID: cuUserID,
Amount: order.TotalAmount,
Status: models.InvoiceStatusPending,
InvoiceType: req.InvoiceType,
Title: req.Title,
TaxpayerID: req.TaxpayerID,
Content: req.Content,
ReceiverEmail: req.ReceiverEmail,
Remark: req.Remark,
CreatedAt: now,
UpdatedAt: now,
}
err = s.repo.Create(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *cuInvoiceService) GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 验证权限:只能查看自己的发票
if invoice.CuUserID != cuUserID {
return nil, i18n.NewI18nError("700001", lang)
}
return invoice, nil
}
func (s *cuInvoiceService) GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 解析日期筛选
var createdAtStart, createdAtEnd *time.Time
if req.ApplyDate != "" {
start, end, err := s.parseDateRange(req.ApplyDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
createdAtStart = start
createdAtEnd = end
}
invoices, total, err := s.repo.GetByCuUserID(ctx, cuUserID, req, createdAtStart, createdAtEnd)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应结构并填充多语言显示字段
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
for i, invoice := range invoices {
invoiceResponses[i] = invoice.ToResponse()
s.fillDisplayFields(invoiceResponses[i], lang)
}
return &models.InvoiceListResponse{
Invoices: invoiceResponses,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
func (s *cuInvoiceService) GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
return s.repo.GetCuUserInvoiceSummary(ctx, cuUserID)
}
func (s *cuInvoiceService) DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error) {
invoice, err := s.GetInvoice(ctx, invoiceID, cuUserID)
if err != nil {
return "", err
}
lang := pkgcontext.GetLanguageFromContext(ctx)
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
return *invoice.InvoiceFileURL, nil
}
// ========== 私有辅助方法 ==========
// fillDisplayFields 填充响应结构的多语言显示字段
func (s *cuInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// generateInvoiceNo 生成发票申请号
func (s *cuInvoiceService) generateInvoiceNo() string {
now := time.Now()
timestamp := now.UnixNano() / int64(time.Millisecond) % 1000000000 // 取纳秒后9位
return fmt.Sprintf("INV%04d%02d%02d%09d",
now.Year(), now.Month(), now.Day(), timestamp)
}
// parseDateRange 解析日期范围
func (s *cuInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
// 假设输入格式为 YYYY-MM-DD
start, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, nil, err
}
end := start.AddDate(0, 0, 1) // 下一天
return &start, &end, nil
}
@@ -0,0 +1,64 @@
-- 发票管理表
-- 支持订单开票申请、管理员处理、文件上传等完整发票管理流程
-- MySQL版本
-- 创建发票表
CREATE TABLE `invoices` (
`id` varchar(36) NOT NULL,
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
`title` varchar(200) NOT NULL COMMENT '发票抬头',
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
`content` varchar(200) NOT NULL COMMENT '开票内容',
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
KEY `idx_invoices_customer_id` (`customer_id`),
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
KEY `idx_invoices_status` (`status`),
KEY `idx_invoices_created_at` (`created_at`),
KEY `idx_invoices_deleted_at` (`deleted_at`),
KEY `idx_invoices_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='发票管理表 - 支持订单开票申请和处理流程';
-- 外键约束
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_order_id` FOREIGN KEY (`order_id`) REFERENCES `cu_orders` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_cu_user_id` FOREIGN KEY (`cu_user_id`) REFERENCES `cu_users` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_uploaded_by` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_rejected_by` FOREIGN KEY (`rejected_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
-- 注意:
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
-- 2. invoice_no全局唯一,作为发票申请的主要标识
-- 3. order_id唯一,确保一个订单最多一张发票
-- 4. 使用软删除,通过deleted_at字段实现
-- 5. 外键约束确保数据一致性,管理员字段允许为NULL