feat: 添加发票管理模块,支持客户申请发票、管理员处理发票、文件上传及下载功能,更新相关文档和国际化错误码
This commit is contained in:
1 parent
3192b79aa4
commit
969b36f1d1
24 files changed
+5625
-278
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 提示词
|
||||
|
||||
## 接口设计提示词
|
||||
你需要像一个经验丰富的开发工程师和架构师一样
|
||||
如果发现架构有问题不合理,应该及时和请求者讨论。
|
||||
如果要设计接口,请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
|
||||
开发接口也一样,要学会参考,按照项目本身的规范做而不是独辟蹊径。
|
||||
有任何无法决策的地方应该主动问请求者而不是随便决策。
|
||||
|
||||
请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
|
||||
接口的请求和响应结构体需要加上中文注释,避免前端看swagger文档还要猜测字段
|
||||
+1314
-29
File diff suppressed because it is too large.
Load diff
+1314
-29
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+4
-7
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"` // 已驳回发票数量
|
||||
}
|
||||
@@ -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 转换为响应结构
|
||||
|
||||
@@ -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 转换为响应结构
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user