diff --git a/apis/public/file.api b/apis/public/file.api index a9b79ba..b2cd9ca 100644 --- a/apis/public/file.api +++ b/apis/public/file.api @@ -16,13 +16,7 @@ type ( } FileUploadResponse { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } FileUploadInitRequest { @@ -47,12 +41,7 @@ type ( } FileUploadCompleteResponse { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } ) diff --git a/apis/types.api b/apis/types.api index 836a358..a5c785e 100644 --- a/apis/types.api +++ b/apis/types.api @@ -265,6 +265,12 @@ type ( PromoPrice int64 `json:"promo_price"` CreatedAt int64 `json:"created_at"` } + SubscribePromo { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` + } TrafficLimit { StatType string `json:"stat_type"` StatValue int64 `json:"stat_value"` @@ -279,6 +285,7 @@ type ( UnitPrice int64 `json:"unit_price"` UnitTime string `json:"unit_time"` Discount []SubscribeDiscount `json:"discount"` + Promo *SubscribePromo `json:"promo"` NodeCount int64 `json:"node_count"` Replacement int64 `json:"replacement"` Inventory int64 `json:"inventory"` @@ -743,6 +750,7 @@ type ( Price int64 `json:"price"` Amount int64 `json:"amount"` Discount int64 `json:"discount"` + PromoDiscount int64 `json:"promo_discount"` GiftAmount int64 `json:"gift_amount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` diff --git a/doc/api-withdrawal-and-log.md b/doc/api-withdrawal-and-log.md new file mode 100644 index 0000000..a597f12 --- /dev/null +++ b/doc/api-withdrawal-and-log.md @@ -0,0 +1,560 @@ +# 提现 & 文件上传 & 日志上报 — 用户端 API 接口文档 + +> 基于 ppanel-server 源码整理,所有时间戳均为**秒级 Unix**。 + +--- + +## 目录 + +- [一、提现接口](#一提现接口) + - [1.1 申请提现](#11-申请提现) + - [1.2 取消提现](#12-取消提现) + - [1.3 查询提现记录](#13-查询提现记录) +- [二、枚举值与状态流转](#二枚举值与状态流转) +- [三、文件上传接口](#三文件上传接口) + - [3.1 直传文件(小文件)](#31-直传文件小文件) + - [3.2 初始化上传(大文件 — 预签名)](#32-初始化上传大文件--预签名) + - [3.3 确认上传完成](#33-确认上传完成) +- [四、日志查询接口 (Admin)](#四日志查询接口-admin) + - [4.1 错误日志列表](#41-错误日志列表) + - [4.2 错误日志详情](#42-错误日志详情) + - [4.3 日志消息原始详情](#43-日志消息原始详情) + +--- + +## 一、提现接口 + +> 认证方式: JWT(用户登录态) +> +> 路由前缀: `/v1/public/user` + +### 1.1 申请提现 + +提交佣金提现申请,创建一条待审核的提现记录。 + +``` +POST /v1/public/user/commission_withdraw +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `amount` | int64 | 是 | — | 提现金额(分) | +| `method` | uint8 | 是 | `oneof=0 1 2 3` | 收款方式(见枚举表) | +| `content` | string | 否 | — | 提现备注 | +| `account` | string | 条件必填 | — | 收款账号 | +| `qr_code_url` | string | 条件必填 | — | 收款码图片 URL | + +**各收款方式的必填字段** + +| method | 收款方式 | 必填字段 | +|--------|---------|---------| +| `1` 支付宝 | `qr_code_url` | 收款码图片 | +| `2` 微信 | `qr_code_url` | 收款码图片 | +| `3` 银行卡 | `account` | 收款账号 | +| `0` 其他 | `account` 必填 | + +**Request 示例** + +```json +{ + "amount": 5000, + "content": "提现到支付宝", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png" +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象) + +--- + +### 1.2 取消提现 + +用户取消自己的待审核提现申请,佣金退回账户。 + +``` +POST /v1/public/user/withdrawal_cancel +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `withdrawal_id` | int64 | 是 | `required,gt=0` | 提现记录 ID | + +**Request 示例** + +```json +{ + "withdrawal_id": 123 +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象)(状态已变为 `3=已取消`) + +--- + +### 1.3 查询提现记录 + +分页查询当前用户的提现记录(自动按 JWT 中的 userId 过滤)。 + +``` +GET /v1/public/user/withdrawal_log +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 否 | 页码,默认 1 | +| `size` | int | 否 | 每页条数,默认 10 | + +**Request 示例** + +``` +GET /v1/public/user/withdrawal_log?page=1&size=10 +``` + +**Response** + +```json +{ + "list": [WithdrawalLog, ...], + "total": 25 +} +``` + +--- + +## 二、枚举值与状态流转 + +### 提现状态 (`status`) + +| 值 | 说明 | +|----|------| +| 0 | 待审核 | +| 1 | 已通过 | +| 2 | 已拒绝 | +| 3 | 已取消 | + +### 收款方式 (`method`) + +| 值 | 说明 | +|----|------| +| 0 | 其他 | +| 1 | 支付宝 | +| 2 | 微信 | +| 3 | 银行卡 | + +### 状态流转 + +``` + ┌── 管理员通过 ──▶ 已通过 (1) + │ +待审核 (0) ──────┼── 管理员拒绝 ──▶ 已拒绝 (2) + │ + └── 用户取消 ───▶ 已取消 (3) +``` + +### WithdrawalLog 对象 + +所有提现接口共用的响应结构: + +```json +{ + "id": 1, + "user_id": 100, + "amount": 5000, + "content": "提现备注", + "status": 0, + "reason": "", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png", + "created_at": 1716700000, + "updated_at": 1716700000 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 提现记录 ID | +| `user_id` | int64 | 用户 ID | +| `amount` | int64 | 提现金额(分) | +| `content` | string | 提现备注 | +| `status` | uint8 | 状态(见枚举表) | +| `reason` | string | 拒绝原因(仅 status=2 时有值,其余 omitempty) | +| `method` | uint8 | 收款方式(见枚举表) | +| `account` | string | 收款账号 | +| `qr_code_url` | string | 收款码图片 URL | +| `created_at` | int64 | 创建时间(秒级 Unix) | +| `updated_at` | int64 | 更新时间(秒级 Unix) | + +--- + +## 三、文件上传接口 + +> 认证方式: JWT + DeviceMiddleware(用户登录态 + 设备认证) +> +> 路由前缀: `/v1/public/file` +> +> 存储后端: S3 兼容(RustFS) + +提供两种上传方式: + +| 方式 | 适用场景 | 流程 | +|------|---------|------| +| **直传** | 小文件(收款码等) | 1 次请求,`multipart/form-data` 直接上传 | +| **预签名** | 大文件 / 客户端直传 S3 | init → 客户端 PUT 到预签名 URL → complete 确认 | + +--- + +### 3.1 直传文件(小文件) + +通过 `multipart/form-data` 直接上传文件到服务端,服务端转存至 S3。 + +``` +POST /v1/public/file/upload +Content-Type: multipart/form-data +``` + +**Form 参数** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `biz_type` | string | 是 | 业务类型(如 `withdrawal_qrcode`、`avatar` 等) | +| `file` | file | 是 | 上传的文件(multipart) | + +**cURL 示例** + +```bash +curl -X POST /v1/public/file/upload \ + -H "Authorization: Bearer " \ + -F "biz_type=withdrawal_qrcode" \ + -F "file=@/path/to/alipay_qr.png" +``` + +**Response** + +```json +{ + "file_id": "a1b2c3d4e5f678901234", + "file_name": "alipay_qr.png", + "object_key": "app-upload/2026/05/27/100/alipay_qr.png__a1b2c3d4e5f678901234", + "size": 52480, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID(24 字符 hex) | +| `file_name` | string | 原始文件名 | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 文件大小(字节) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | 状态,直传成功即 `completed` | + +--- + +### 3.2 初始化上传(大文件 — 预签名) + +获取 S3 预签名 URL,客户端直接 PUT 到 S3,避免文件经过服务端。 + +``` +POST /v1/public/file/upload/init +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `biz_type` | string | 是 | `required` | 业务类型 | +| `file_name` | string | 是 | `required` | 文件名 | +| `content_type` | string | 是 | `required` | MIME 类型(如 `image/png`) | +| `size` | int64 | 是 | `required` | 文件大小(字节) | +| `sha256` | string | 否 | — | 文件 SHA256(可选校验) | + +**Request 示例** + +```json +{ + "biz_type": "withdrawal_qrcode", + "file_name": "wechat_qr.png", + "content_type": "image/png", + "size": 102400, + "sha256": "e3b0c44298fc1c149afbf4c8996fb924..." +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "upload_url": "https://s3.example.com/bucket/app-upload/...?X-Amz-Signature=...", + "method": "PUT", + "headers": { + "Content-Type": "image/png" + }, + "expired_at": 1716700300 +} +``` + +**FileUploadInitResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `upload_url` | string | 预签名上传 URL | +| `method` | string | HTTP 方法(`PUT`) | +| `headers` | map | 上传时需携带的请求头 | +| `expired_at` | int64 | 预签名过期时间(秒级 Unix,默认 300 秒) | + +**客户端上传流程** + +``` +1. 调用 /upload/init 获取 upload_url +2. 用返回的 method + headers 直接上传文件到 upload_url +3. 上传成功后调用 /upload/complete 确认 +``` + +--- + +### 3.3 确认上传完成 + +客户端通过预签名 URL 上传完成后,调用此接口确认文件状态。 + +``` +POST /v1/public/file/upload/complete +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `file_id` | string | 是 | `required` | init 返回的 file_id | + +**Request 示例** + +```json +{ + "file_id": "b2c3d4e5f6789012345a" +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "size": 102400, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadCompleteResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 实际文件大小(S3 HeadObject 获取) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | `completed` | + +**校验规则** + +- 文件大小不能超过配置的 `S3.MaxUploadSize` +- Content-Type 必须在配置的 `S3.AllowedContentTypes` 白名单内(若配置了) +- complete 时会校验 S3 上的实际文件大小是否与 init 声明的一致 +- 只能确认自己发起的上传(userId 校验) + +--- + +## 四、日志查询接口 (Admin) + +> 认证方式: AuthMiddleware(管理员权限) +> +> 路由前缀: `/v1/admin/log` +> +> 数据来源: `log_message` 表(客户端上报的错误/崩溃日志) + +--- + +### 4.1 错误日志列表 + +分页查询客户端上报的错误日志,支持多维度筛选。 + +``` +GET /v1/admin/log/error_message/list +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 是 | 页码 | +| `size` | int | 是 | 每页条数 | +| `platform` | string | 否 | 平台筛选(ios / android / windows / mac / harmony) | +| `level` | uint8 | 否 | 日志级别 | +| `user_id` | int64 | 否 | 用户 ID | +| `device_id` | string | 否 | 设备 ID | +| `error_code` | string | 否 | 错误码 | +| `keyword` | string | 否 | 关键字搜索(匹配 message) | +| `start` | int64 | 否 | 开始时间(秒级 Unix) | +| `end` | int64 | 否 | 结束时间(秒级 Unix) | + +**Request 示例** + +``` +GET /v1/admin/log/error_message/list?page=1&size=20&platform=ios&start=1716600000&end=1716700000 +``` + +**Response** + +```json +{ + "total": 50, + "list": [ + { + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "created_at": 1716700000 + } + ] +} +``` + +**ErrorLogMessage 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 日志 ID | +| `platform` | string | 平台 | +| `app_version` | string | 客户端版本 | +| `os_name` | string | 操作系统名称 | +| `os_version` | string | 操作系统版本 | +| `device_id` | string | 设备 ID | +| `user_id` | int64 | 用户 ID | +| `session_id` | string | 会话 ID | +| `level` | uint8 | 日志级别 | +| `error_code` | string | 错误码 | +| `message` | string | 错误消息 | +| `created_at` | int64 | 创建时间(秒级 Unix) | + +--- + +### 4.2 错误日志详情 + +获取单条错误日志的完整详情(列表字段 + 堆栈/IP/UA 等扩展信息)。 + +``` +GET /v1/admin/log/error_message/detail +``` + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比列表额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `stack` | string | 堆栈信息 | +| `client_ip` | string | 客户端 IP | +| `user_agent` | string | User-Agent | +| `locale` | string | 客户端语言/地区 | +| `occurred_at` | int64 | 错误发生时间(秒级 Unix) | + +--- + +### 4.3 日志消息原始详情 + +获取单条 `log_message` 的完整原始数据(含 context、digest 等全量字段)。 + +``` +GET /v1/admin/log/message/detail +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | int64 | 是 | 日志消息 ID | + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "context": { "server_id": 5, "protocol": "vmess" }, + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "digest": "sha256_abc123...", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比详情额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `context` | any | 附加上下文(原始 JSON) | +| `digest` | string | 内容摘要(用于去重) | diff --git a/initialize/migrate/database/02152_withdrawal_method.up.sql b/initialize/migrate/database/02152_withdrawal_method.up.sql index 57f3104..84ecbc2 100644 --- a/initialize/migrate/database/02152_withdrawal_method.up.sql +++ b/initialize/migrate/database/02152_withdrawal_method.up.sql @@ -1,4 +1,10 @@ -ALTER TABLE `withdrawals` - ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`, - ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`, - ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`; +SELECT COUNT(*) INTO @col_exists FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawals' AND COLUMN_NAME = 'method'; + +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE `withdrawals` ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 AFTER `content`, ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '''' AFTER `method`, ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '''' AFTER `account`', + 'SELECT 1'); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/initialize/migrate/database/02154_promo_admin_api.down.sql b/initialize/migrate/database/02154_promo_admin_api.down.sql deleted file mode 100644 index a6103d3..0000000 --- a/initialize/migrate/database/02154_promo_admin_api.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP TABLE IF EXISTS `promo_usage`; -DROP TABLE IF EXISTS `subscribe_promo`; -DROP TABLE IF EXISTS `promo_rule`; diff --git a/initialize/migrate/database/02154_promo_system.down.sql b/initialize/migrate/database/02154_promo_system.down.sql new file mode 100644 index 0000000..ea3d471 --- /dev/null +++ b/initialize/migrate/database/02154_promo_system.down.sql @@ -0,0 +1,39 @@ +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_discount`', + 'SELECT ''Column promo_discount does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_rule_id`', + 'SELECT ''Column promo_rule_id does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TABLE IF EXISTS `promo_usage`; +DROP TABLE IF EXISTS `subscribe_promo`; +DROP TABLE IF EXISTS `promo_rule`; diff --git a/initialize/migrate/database/02154_promo_admin_api.up.sql b/initialize/migrate/database/02154_promo_system.up.sql similarity index 59% rename from initialize/migrate/database/02154_promo_admin_api.up.sql rename to initialize/migrate/database/02154_promo_system.up.sql index e91709a..be9fe24 100644 --- a/initialize/migrate/database/02154_promo_admin_api.up.sql +++ b/initialize/migrate/database/02154_promo_system.up.sql @@ -18,21 +18,21 @@ CREATE TABLE IF NOT EXISTS `promo_rule` ( CREATE TABLE IF NOT EXISTS `subscribe_promo` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', - `quantity` INT NOT NULL DEFAULT 0 COMMENT '购买数量', + `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT '购买数量', `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', - `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '优惠价(分)', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `idx_subscribe_qty_rule` (`subscribe_id`, `quantity`, `promo_rule_id`), + UNIQUE KEY `uk_subscribe_qty_rule` (`subscribe_id`, `quantity`, `promo_rule_id`), KEY `idx_promo_rule_id` (`promo_rule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; CREATE TABLE IF NOT EXISTS `promo_usage` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', - `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', - `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID', + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID', `order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号', `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -40,3 +40,39 @@ CREATE TABLE IF NOT EXISTS `promo_usage` ( KEY `idx_user_rule` (`user_id`, `promo_rule_id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`', + 'SELECT ''Column promo_rule_id already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`', + 'SELECT ''Column promo_discount already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/handler/routes.go b/internal/handler/routes.go index c55af69..09e9f7a 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1011,17 +1011,16 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { } publicSubscribeGroupRouter := router.Group("/v1/public/subscribe") - publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) { // Get subscribe list - publicSubscribeGroupRouter.GET("/list", publicSubscribe.QuerySubscribeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/list", middleware.OptionalAuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeListHandler(serverCtx)) // Get user subscribe node info - publicSubscribeGroupRouter.GET("/node/list", publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/node/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) // Get subscribe group list - publicSubscribeGroupRouter.GET("/group/list", publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/group/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) } publicTicketGroupRouter := router.Group("/v1/public/ticket") diff --git a/internal/logic/admin/application/deleteSubscribeApplicationLogic.go b/internal/logic/admin/application/deleteSubscribeApplicationLogic.go index 57cdd70..1b8456d 100644 --- a/internal/logic/admin/application/deleteSubscribeApplicationLogic.go +++ b/internal/logic/admin/application/deleteSubscribeApplicationLogic.go @@ -29,7 +29,7 @@ func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types. err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id) if err != nil { l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error()) } return nil } diff --git a/internal/logic/admin/promo/createRuleLogic.go b/internal/logic/admin/promo/createRuleLogic.go index ceefdb8..f6d9eb1 100644 --- a/internal/logic/admin/promo/createRuleLogic.go +++ b/internal/logic/admin/promo/createRuleLogic.go @@ -42,7 +42,7 @@ func (l *CreateRuleLogic) CreateRule(req *types.CreatePromoRuleRequest) (*types. Type: req.Type, Params: params, Priority: req.Priority, - Enabled: &enabled, + Enabled: enabled, StartTime: unixPtrToTimePtr(req.StartTime), EndTime: unixPtrToTimePtr(req.EndTime), } diff --git a/internal/logic/admin/promo/tool.go b/internal/logic/admin/promo/tool.go index 54898f8..6c72881 100644 --- a/internal/logic/admin/promo/tool.go +++ b/internal/logic/admin/promo/tool.go @@ -113,17 +113,13 @@ func convertRule(item *promomodel.Rule) types.PromoRule { if item == nil { return types.PromoRule{} } - enabled := false - if item.Enabled != nil { - enabled = *item.Enabled - } return types.PromoRule{ Id: item.Id, Name: item.Name, Type: item.Type, Params: parseParams(item.Params), Priority: item.Priority, - Enabled: enabled, + Enabled: item.Enabled, StartTime: timePtrToUnixPtr(item.StartTime), EndTime: timePtrToUnixPtr(item.EndTime), CreatedAt: item.CreatedAt.Unix(), diff --git a/internal/logic/admin/promo/updateRuleLogic.go b/internal/logic/admin/promo/updateRuleLogic.go index 4ccd408..b975b04 100644 --- a/internal/logic/admin/promo/updateRuleLogic.go +++ b/internal/logic/admin/promo/updateRuleLogic.go @@ -37,10 +37,7 @@ func (l *UpdateRuleLogic) UpdateRule(req *types.UpdatePromoRuleRequest) (*types. if err != nil { return nil, err } - enabled := true - if rule.Enabled != nil { - enabled = *rule.Enabled - } + enabled := rule.Enabled if req.Enabled != nil { enabled = *req.Enabled } @@ -48,7 +45,7 @@ func (l *UpdateRuleLogic) UpdateRule(req *types.UpdatePromoRuleRequest) (*types. rule.Type = req.Type rule.Params = params rule.Priority = req.Priority - rule.Enabled = &enabled + rule.Enabled = enabled rule.StartTime = unixPtrToTimePtr(req.StartTime) rule.EndTime = unixPtrToTimePtr(req.EndTime) if err := l.svcCtx.PromoModel.UpdateRule(l.ctx, rule); err != nil { diff --git a/internal/logic/admin/server/resetSortWithNodeLogic.go b/internal/logic/admin/server/resetSortWithNodeLogic.go index 3866f54..c44b9dd 100644 --- a/internal/logic/admin/server/resetSortWithNodeLogic.go +++ b/internal/logic/admin/server/resetSortWithNodeLogic.go @@ -80,7 +80,7 @@ func (l *ResetSortWithNodeLogic) ResetSortWithNode(req *types.ResetSortRequest) }) if err != nil { l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } return nil } diff --git a/internal/logic/admin/server/resetSortWithServerLogic.go b/internal/logic/admin/server/resetSortWithServerLogic.go index 3fbe237..5851b13 100644 --- a/internal/logic/admin/server/resetSortWithServerLogic.go +++ b/internal/logic/admin/server/resetSortWithServerLogic.go @@ -80,7 +80,7 @@ func (l *ResetSortWithServerLogic) ResetSortWithServer(req *types.ResetSortReque }) if err != nil { l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } return nil } diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go new file mode 100644 index 0000000..885c7c0 --- /dev/null +++ b/internal/logic/common/promoEligibility.go @@ -0,0 +1,170 @@ +package common + +import ( + "context" + "encoding/json" + "time" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type PromoResult struct { + Eligible bool + RuleID int64 + RuleName string + RuleType string + PromoPrice int64 + ExpiresAt time.Time +} + +type promoRuleParams struct { + WindowHours int `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) { + result := &PromoResult{} + if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 || quantity <= 0 { + return result, nil + } + + rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID, quantity) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error()) + } + if len(rules) == 0 { + return result, nil + } + + var currentUser user.User + now := time.Now() + for _, rule := range rules { + if rule == nil || !isPromoRuleInTimeWindow(rule, now) { + continue + } + if rule.PromoPrice <= 0 { + continue + } + + params := promoRuleParams{} + if rule.Params != "" { + if err = json.Unmarshal([]byte(rule.Params), ¶ms); err != nil { + continue + } + } + + eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, ¤tUser, now) + if err != nil { + return nil, err + } + if !eligible { + continue + } + + return &PromoResult{ + Eligible: true, + RuleID: rule.Id, + RuleName: rule.Name, + RuleType: rule.Type, + PromoPrice: rule.PromoPrice, + ExpiresAt: expiresAt, + }, nil + } + + return result, nil +} + +func isPromoRuleInTimeWindow(rule *promo.RuleWithPrice, now time.Time) bool { + if rule.StartTime != nil && !rule.StartTime.IsZero() && now.Before(*rule.StartTime) { + return false + } + if rule.EndTime != nil && !rule.EndTime.IsZero() && now.After(*rule.EndTime) { + return false + } + return true +} + +func evaluatePromoRule( + ctx context.Context, + db *gorm.DB, + rule *promo.RuleWithPrice, + params promoRuleParams, + userID int64, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + switch rule.Type { + case promo.RuleTypeNewUser: + return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now) + case promo.RuleTypeInactiveUser: + return evaluateInactiveUserPromo(ctx, db, params, userID, promoRuleExpiresAt(rule), now) + case promo.RuleTypeCampaign: + return true, promoRuleExpiresAt(rule), nil + default: + return false, time.Time{}, nil + } +} + +func evaluateNewUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + if params.WindowHours <= 0 { + return false, time.Time{}, nil + } + + if currentUser.Id == 0 { + if err := db.WithContext(ctx).Model(&user.User{}).Where("id = ?", userID).First(currentUser).Error; err != nil { + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user failed") + } + } + + expiresAt := currentUser.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt, nil +} + +func evaluateInactiveUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + ruleExpiresAt time.Time, + now time.Time, +) (bool, time.Time, error) { + if params.InactiveMonths <= 0 { + return false, time.Time{}, nil + } + + var lastSub user.Subscribe + err := db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", userID). + Order("expire_time DESC"). + Limit(1). + Take(&lastSub).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return true, ruleExpiresAt, nil + } + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") + } + + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return lastSub.ExpireTime.Before(threshold) || lastSub.ExpireTime.Equal(threshold), ruleExpiresAt, nil +} + +func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time { + if rule != nil && rule.EndTime != nil { + return *rule.EndTime + } + return time.Time{} +} diff --git a/internal/logic/public/file/fileuploadcompletelogic.go b/internal/logic/public/file/fileuploadcompletelogic.go index 490c000..925ab25 100644 --- a/internal/logic/public/file/fileuploadcompletelogic.go +++ b/internal/logic/public/file/fileuploadcompletelogic.go @@ -61,11 +61,6 @@ func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadComple } return &types.FileUploadCompleteResponse{ - FileId: meta.FileID, - ObjectKey: meta.ObjectKey, - Size: head.ContentLength, - ContentType: head.ContentType, - Etag: head.ETag, - Status: meta.Status, + Url: l.svcCtx.S3Store.BuildObjectURL(meta.ObjectKey), }, nil } diff --git a/internal/logic/public/file/fileuploadlogic.go b/internal/logic/public/file/fileuploadlogic.go index b9f2a7b..05d9c3a 100644 --- a/internal/logic/public/file/fileuploadlogic.go +++ b/internal/logic/public/file/fileuploadlogic.go @@ -52,20 +52,13 @@ func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest, fileHeader *m fileID := buildFileID(u.Id, req.BizType, fileHeader.Filename) objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, fileHeader.Filename, now) - putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType) - if err != nil { + if _, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType); err != nil { l.Errorw("put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("file_id", fileID)) return nil, err } return &types.FileUploadResponse{ - FileId: fileID, - FileName: fileHeader.Filename, - ObjectKey: objectKey, - Size: fileHeader.Size, - ContentType: contentType, - Etag: putResult.ETag, - Status: fileUploadCompleteStatus, + Url: l.svcCtx.S3Store.BuildObjectURL(objectKey), }, nil } diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index fc07fb0..ac9ba92 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -2,7 +2,6 @@ package order import ( "context" - "math" commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/order" @@ -115,14 +114,28 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user") } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + u.Id, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + !isSingleModeRenewal, + ) + if err != nil { + l.Errorw("[PreCreateOrder] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount var couponAmount int64 if req.Coupon != "" { couponInfo, err := l.svcCtx.CouponModel.FindOneByCode(l.ctx, req.Coupon) @@ -185,6 +198,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r Price: price, Amount: amount, Discount: discountAmount, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: couponAmount, diff --git a/internal/logic/public/order/promoPricing.go b/internal/logic/public/order/promoPricing.go new file mode 100644 index 0000000..77b6e93 --- /dev/null +++ b/internal/logic/public/order/promoPricing.go @@ -0,0 +1,62 @@ +package order + +import ( + "context" + "math" + + commonLogic "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" +) + +type orderPriceResult struct { + OriginalPrice int64 + PayableBase int64 + DiscountAmount int64 + PromoRuleId int64 + PromoDiscount int64 + PromoPrice int64 +} + +func calculatePurchasePrice( + ctx context.Context, + svcCtx *svc.ServiceContext, + userID int64, + subscribeID int64, + unitPrice int64, + quantity int64, + discounts []types.SubscribeDiscount, + eligibleForDiscount bool, + allowPromo bool, +) (*orderPriceResult, error) { + originalPrice := unitPrice * quantity + result := &orderPriceResult{ + OriginalPrice: originalPrice, + PayableBase: originalPrice, + } + + if allowPromo { + promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity) + if err != nil { + return nil, err + } + if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < unitPrice { + result.PayableBase = promoResult.PromoPrice * quantity + result.PromoRuleId = promoResult.RuleID + result.PromoDiscount = originalPrice - result.PayableBase + result.PromoPrice = promoResult.PromoPrice + if result.PromoDiscount < 0 { + result.PromoDiscount = 0 + } + return result, nil + } + } + + discount := float64(1) + if len(discounts) > 0 { + discount = getDiscount(discounts, quantity, eligibleForDiscount) + } + result.PayableBase = int64(math.Round(float64(originalPrice) * discount)) + result.DiscountAmount = originalPrice - result.PayableBase + return result, nil +} diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go new file mode 100644 index 0000000..fc89213 --- /dev/null +++ b/internal/logic/public/order/promoPricing_test.go @@ -0,0 +1,157 @@ +package order + +import ( + "context" + "testing" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "gorm.io/gorm" +) + +type fakePromoModel struct { + rules []*promo.RuleWithPrice +} + +func (m fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promo.RuleWithPrice, error) { + return m.rules, nil +} + +func (m fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { + return nil +} + +func (m fakePromoModel) InsertRule(context.Context, *promo.Rule) error { + return nil +} + +func (m fakePromoModel) FindRule(context.Context, int64) (*promo.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m fakePromoModel) UpdateRule(context.Context, *promo.Rule) error { + return nil +} + +func (m fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (m fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promo.Rule, error) { + return 0, nil, nil +} + +func (m fakePromoModel) UpsertPrices(context.Context, int64, []*promo.SubscribePromo) error { + return nil +} + +func (m fakePromoModel) FindPrice(context.Context, int64) (*promo.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (m fakePromoModel) QueryPriceList(context.Context, int64, int, int) (int64, []*promo.SubscribePromo, error) { + return 0, nil, nil +} + +func (m fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int64, []*promo.Usage, error) { + return 0, nil, nil +} + +func (m fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 9, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 600, + }, + }}, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.OriginalPrice != 3000 { + t.Fatalf("OriginalPrice = %d, want 3000", result.OriginalPrice) + } + if result.PayableBase != 1800 { + t.Fatalf("PayableBase = %d, want 1800", result.PayableBase) + } + if result.DiscountAmount != 0 { + t.Fatalf("DiscountAmount = %d, want 0", result.DiscountAmount) + } + if result.PromoRuleId != 9 { + t.Fatalf("PromoRuleId = %d, want 9", result.PromoRuleId) + } + if result.PromoDiscount != 1200 { + t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount) + } +} + +func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) { + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 10, + Name: "invalid campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 1000, + }, + }}, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.PayableBase != 1500 { + t.Fatalf("PayableBase = %d, want 1500", result.PayableBase) + } + if result.DiscountAmount != 1500 { + t.Fatalf("DiscountAmount = %d, want 1500", result.DiscountAmount) + } + if result.PromoRuleId != 0 || result.PromoDiscount != 0 { + t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount) + } +} diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index df02908..772ad74 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -3,7 +3,6 @@ package order import ( "context" "encoding/json" - "math" "strings" "time" @@ -204,14 +203,28 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P return nil, err } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + u.Id, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + orderType == 1, + ) + if err != nil { + l.Errorw("[Purchase] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - // discount amount - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount // Validate amount to prevent overflow if amount > MaxOrderAmount { @@ -306,6 +319,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P Price: price, Amount: amount, Discount: discountAmount, + PromoRuleId: priceResult.PromoRuleId, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: coupon, diff --git a/internal/logic/public/subscribe/promo.go b/internal/logic/public/subscribe/promo.go new file mode 100644 index 0000000..1456d3c --- /dev/null +++ b/internal/logic/public/subscribe/promo.go @@ -0,0 +1,224 @@ +package subscribe + +import ( + "context" + "encoding/json" + stderrors "errors" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + promoRuleTypeNewUser = "new_user" + promoRuleTypeInactiveUser = "inactive_user" + promoRuleTypeCampaign = "campaign" +) + +type subscribePromoCandidate struct { + SubscribeId int64 `gorm:"column:subscribe_id"` + RuleName string `gorm:"column:rule_name"` + RuleType string `gorm:"column:rule_type"` + PromoPrice int64 `gorm:"column:promo_price"` + Params string `gorm:"column:params"` + StartTime *time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` +} + +type promoRuleParams struct { + WindowHours int64 `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]*types.SubscribePromo, error) { + result := make(map[int64]*types.SubscribePromo) + if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil { + return result, nil + } + + userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User) + candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil) + if err != nil { + if isMissingPromoTableError(err) { + return result, nil + } + return nil, err + } + + evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo} + now := time.Now() + for _, candidate := range candidates { + if _, exists := result[candidate.SubscribeId]; exists { + continue + } + if !candidate.isActive(now) { + continue + } + ok, expiresAt, err := evaluator.match(candidate, now) + if err != nil { + return nil, err + } + if !ok { + continue + } + result[candidate.SubscribeId] = &types.SubscribePromo{ + RuleName: candidate.RuleName, + RuleType: candidate.RuleType, + PromoPrice: candidate.PromoPrice, + ExpiresAt: unixSeconds(expiresAt), + } + } + + return result, nil +} + +func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) { + var candidates []subscribePromoCandidate + query := svcCtx.DB.WithContext(ctx). + Table("subscribe_promo AS sp"). + Select("sp.subscribe_id, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time"). + Joins("JOIN promo_rule AS pr ON pr.id = sp.promo_rule_id AND pr.deleted_at IS NULL"). + Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true) + if !loggedIn { + query = query.Where("pr.type = ?", promoRuleTypeCampaign) + } + err := query. + Order("sp.subscribe_id ASC"). + Order("pr.priority DESC"). + Order("pr.id ASC"). + Scan(&candidates).Error + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err) + } + return candidates, nil +} + +func (c subscribePromoCandidate) isActive(now time.Time) bool { + if c.PromoPrice <= 0 { + return false + } + if c.StartTime != nil && now.Before(*c.StartTime) { + return false + } + if c.EndTime != nil && now.After(*c.EndTime) { + return false + } + return true +} + +type promoEligibilityEvaluator struct { + ctx context.Context + db *gorm.DB + userInfo *user.User + lastExpire *time.Time +} + +func (e *promoEligibilityEvaluator) match(candidate subscribePromoCandidate, now time.Time) (bool, time.Time, error) { + switch candidate.RuleType { + case promoRuleTypeCampaign: + return true, candidate.expiresAt(), nil + case promoRuleTypeNewUser: + if e.userInfo == nil { + return false, time.Time{}, nil + } + params, err := candidate.params() + if err != nil { + return false, time.Time{}, err + } + if params.WindowHours <= 0 || e.userInfo.CreatedAt.IsZero() { + return false, time.Time{}, nil + } + expiresAt := e.userInfo.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt, nil + case promoRuleTypeInactiveUser: + if e.userInfo == nil { + return false, time.Time{}, nil + } + params, err := candidate.params() + if err != nil { + return false, time.Time{}, err + } + if params.InactiveMonths <= 0 { + return false, time.Time{}, nil + } + lastExpire, err := e.lastSubscribeExpireAt() + if err != nil { + return false, time.Time{}, err + } + if lastExpire.Equal(time.UnixMilli(0)) || lastExpire.After(now) { + return false, time.Time{}, nil + } + if lastExpire.IsZero() { + return true, candidate.expiresAt(), nil + } + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return !lastExpire.After(threshold), candidate.expiresAt(), nil + default: + return false, time.Time{}, nil + } +} + +func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) { + if e.lastExpire != nil { + return *e.lastExpire, nil + } + var item user.Subscribe + err := e.db.WithContext(e.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", e.userInfo.Id). + Where("expire_time != ?", time.UnixMilli(0)). + Order("expire_time DESC"). + Limit(1). + Take(&item).Error + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + zero := time.Time{} + e.lastExpire = &zero + return zero, nil + } + return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user last subscription failed") + } + e.lastExpire = &item.ExpireTime + return item.ExpireTime, nil +} + +func (c subscribePromoCandidate) expiresAt() time.Time { + if c.EndTime == nil { + return time.Time{} + } + return *c.EndTime +} + +func (c subscribePromoCandidate) params() (promoRuleParams, error) { + if c.Params == "" { + return promoRuleParams{}, nil + } + var params promoRuleParams + if err := json.Unmarshal([]byte(c.Params), ¶ms); err != nil { + return promoRuleParams{}, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse promo rule params failed") + } + return params, nil +} + +func unixSeconds(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func isMissingPromoTableError(err error) bool { + var mysqlErr *mysql.MySQLError + if stderrors.As(err, &mysqlErr) { + return mysqlErr.Number == 1146 + } + return strings.Contains(err.Error(), "Error 1146") +} diff --git a/internal/logic/public/subscribe/promo_test.go b/internal/logic/public/subscribe/promo_test.go new file mode 100644 index 0000000..f62e75b --- /dev/null +++ b/internal/logic/public/subscribe/promo_test.go @@ -0,0 +1,75 @@ +package subscribe + +import ( + "testing" + "time" + + "github.com/perfect-panel/server/internal/model/user" +) + +func TestPromoEligibilityEvaluatorMatch(t *testing.T) { + now := time.Unix(1710000000, 0) + campaignEnd := now.Add(2 * time.Hour) + + campaign := subscribePromoCandidate{ + RuleName: "限时活动", + RuleType: promoRuleTypeCampaign, + PromoPrice: 99, + EndTime: &campaignEnd, + } + ok, expiresAt, err := (&promoEligibilityEvaluator{}).match(campaign, now) + if err != nil { + t.Fatalf("campaign match error: %v", err) + } + if !ok { + t.Fatal("campaign promo should match without login") + } + if got, want := unixSeconds(expiresAt), campaignEnd.Unix(); got != want { + t.Fatalf("campaign expires_at = %d, want %d", got, want) + } + + newUser := subscribePromoCandidate{ + RuleName: "新客7天优惠", + RuleType: promoRuleTypeNewUser, + PromoPrice: 279, + Params: `{"window_hours":168}`, + } + ok, _, err = (&promoEligibilityEvaluator{}).match(newUser, now) + if err != nil { + t.Fatalf("anonymous new_user match error: %v", err) + } + if ok { + t.Fatal("new_user promo should not match without login") + } + + userInfo := &user.User{Id: 1, CreatedAt: now.Add(-24 * time.Hour)} + ok, expiresAt, err = (&promoEligibilityEvaluator{userInfo: userInfo}).match(newUser, now) + if err != nil { + t.Fatalf("logged-in new_user match error: %v", err) + } + if !ok { + t.Fatal("new_user promo should match inside window") + } + if got, want := unixSeconds(expiresAt), userInfo.CreatedAt.Add(168*time.Hour).Unix(); got != want { + t.Fatalf("new_user expires_at = %d, want %d", got, want) + } +} + +func TestSubscribePromoCandidateActiveWindow(t *testing.T) { + now := time.Unix(1710000000, 0) + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + + if !(subscribePromoCandidate{PromoPrice: 1, StartTime: &start, EndTime: &end}).isActive(now) { + t.Fatal("candidate inside active window should be active") + } + if (subscribePromoCandidate{PromoPrice: 0, StartTime: &start, EndTime: &end}).isActive(now) { + t.Fatal("candidate with zero promo price should not be active") + } + if (subscribePromoCandidate{PromoPrice: 1, StartTime: &end}).isActive(now) { + t.Fatal("candidate before start time should not be active") + } + if (subscribePromoCandidate{PromoPrice: 1, EndTime: &start}).isActive(now) { + t.Fatal("candidate after end time should not be active") + } +} diff --git a/internal/logic/public/subscribe/querySubscribeListLogic.go b/internal/logic/public/subscribe/querySubscribeListLogic.go index f2c80cf..4b7dda0 100644 --- a/internal/logic/public/subscribe/querySubscribeListLogic.go +++ b/internal/logic/public/subscribe/querySubscribeListLogic.go @@ -47,9 +47,11 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi Total: total, } list := make([]types.Subscribe, len(data)) + subscribeIDs := make([]int64, 0, len(data)) for i, item := range data { var sub types.Subscribe tool.DeepCopy(&sub, item) + subscribeIDs = append(subscribeIDs, sub.Id) if item.Discount != "" { var discount []types.SubscribeDiscount _ = json.Unmarshal([]byte(item.Discount), &discount) @@ -69,6 +71,15 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi } } + promos, err := loadSubscribePromoMap(l.ctx, l.svcCtx, subscribeIDs) + if err != nil { + l.Errorw("[QuerySubscribeListLogic] Query Promo Error", logger.Field("error", err.Error())) + return nil, err + } + for i := range list { + list[i].Promo = promos[list[i].Id] + } + resp.List = list resp.Total = int64(len(list)) return diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index 64546c1..162284b 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -46,8 +46,8 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer") } default: // WithdrawalMethodOther - if req.Account == "" && req.Content == "" { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods") + if req.Account == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for other methods") } } diff --git a/internal/logic/public/user/ws/deviceWsConnectLogic.go b/internal/logic/public/user/ws/deviceWsConnectLogic.go index 9e56e12..14a32f5 100644 --- a/internal/logic/public/user/ws/deviceWsConnectLogic.go +++ b/internal/logic/public/user/ws/deviceWsConnectLogic.go @@ -39,14 +39,14 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error { value, _ = c.GetQuery("identifier") if value == nil || value.(string) == "" { l.Errorf("DeviceWsConnectLogic DeviceWsConnect identifier is empty") - return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "identifier is empty") + return errors.Wrap(xerr.NewErrCode(xerr.InvalidParams), "identifier is empty") } } identifier := value.(string) _, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier) if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) { l.Errorf("DeviceWsConnectLogic DeviceWsConnect FindOneDeviceByIdentifier err: %v", err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } value = l.ctx.Value(constant.CtxKeyUser) @@ -67,7 +67,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error { err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device) if err != nil { l.Errorf("DeviceWsConnectLogic DeviceWsConnect InsertDevice err: %v", err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) } } //默认在线设备1 diff --git a/internal/middleware/authMiddleware.go b/internal/middleware/authMiddleware.go index 94f6814..617d2ec 100644 --- a/internal/middleware/authMiddleware.go +++ b/internal/middleware/authMiddleware.go @@ -22,77 +22,92 @@ import ( func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { - ctx := c.Request.Context() + if !authenticateRequest(c, svc, c.GetHeader("Authorization"), true) { + return + } + c.Next() + } +} - jwtConfig := svc.Config.JwtAuth - // get token from header +func OptionalAuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { + c.Next() + return + } + if !authenticateRequest(c, svc, token, false) { + return + } + c.Next() + } +} + +func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string, requireToken bool) bool { + ctx := c.Request.Context() + + if token == "" { + if requireToken { logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Token Empty") result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenEmpty), "Token Empty")) c.Abort() - return } - // parse token - claims, err := jwt.ParseJwtToken(token, jwtConfig.AccessSecret) - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ParseJwtToken", logger.Field("error", err.Error()), logger.Field("token", token)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenExpire), "Token Invalid")) - c.Abort() - return - } - - loginType := parseLoginType(claims) - if claims["identifier"] != nil { - ctx = context.WithValue(ctx, constant.CtxKeyIdentifier, claims["identifier"].(string)) - } - // get user id from token - userId := int64(claims["UserId"].(float64)) - // get session id from token - sessionId := claims["SessionId"].(string) - // get session id from redis - sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - value, err := svc.Redis.Get(c, sessionIdCacheKey).Result() - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Redis Get", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - - //verify user id - if value != fmt.Sprintf("%v", userId) { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Invalid Access", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - - // sliding session: refresh TTL on every active request - svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second) - - userInfo, err := svc.UserModel.FindOne(c, userId) - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error")) - c.Abort() - return - } - // admin verify - paths := strings.Split(c.Request.URL.Path, "/") - if tool.StringSliceContains(paths, "admin") && !*userInfo.IsAdmin { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Not Admin User", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - ctx = context.WithValue(ctx, constant.CtxLoginType, loginType) - ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) - ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) - - c.Request = c.Request.WithContext(ctx) - c.Next() + return !requireToken } + + claims, err := jwt.ParseJwtToken(token, svc.Config.JwtAuth.AccessSecret) + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ParseJwtToken", logger.Field("error", err.Error()), logger.Field("token", token)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenExpire), "Token Invalid")) + c.Abort() + return false + } + + loginType := parseLoginType(claims) + if claims["identifier"] != nil { + ctx = context.WithValue(ctx, constant.CtxKeyIdentifier, claims["identifier"].(string)) + } + userId := int64(claims["UserId"].(float64)) + sessionId := claims["SessionId"].(string) + sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) + value, err := svc.Redis.Get(c, sessionIdCacheKey).Result() + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Redis Get", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + + if value != fmt.Sprintf("%v", userId) { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Invalid Access", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + + svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second) + + userInfo, err := svc.UserModel.FindOne(c, userId) + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error")) + c.Abort() + return false + } + + paths := strings.Split(c.Request.URL.Path, "/") + if tool.StringSliceContains(paths, "admin") && !*userInfo.IsAdmin { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Not Admin User", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + ctx = context.WithValue(ctx, constant.CtxLoginType, loginType) + ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) + ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) + + c.Request = c.Request.WithContext(ctx) + return true } func parseLoginType(claims map[string]interface{}) string { diff --git a/internal/model/order/model.go b/internal/model/order/model.go index 89d1b5f..cfb6b46 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -22,6 +22,8 @@ type Details struct { Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` Discount int64 `gorm:"type:int;not null;default:0;comment:Order Discount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount"` PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Id"` diff --git a/internal/model/order/order.go b/internal/model/order/order.go index cd90153..f351273 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -3,32 +3,34 @@ package order import "time" type Order struct { - Id int64 `gorm:"primaryKey"` - ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` + Id int64 `gorm:"primaryKey"` + ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"` SubscriptionUserId int64 `gorm:"type:bigint;not null;default:0;comment:Target user ID for subscription (0=same as UserId)"` - OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` - Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` - Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` - Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` - Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` - GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` - Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` - Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` - CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` - Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` - PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` - Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` - FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` - TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` - Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` - SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` - SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` - AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` - ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` - IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` - CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` + Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` + Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` + Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` + Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` + GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` + Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` + Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` + CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` + Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` + PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` + Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` + FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` + TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` + Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` + SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` + SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` + AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` + ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` + IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } type OrdersTotal struct { diff --git a/internal/model/promo/default.go b/internal/model/promo/default.go deleted file mode 100644 index 005a2e2..0000000 --- a/internal/model/promo/default.go +++ /dev/null @@ -1,239 +0,0 @@ -package promo - -import ( - "context" - "errors" - "fmt" - - "github.com/perfect-panel/server/pkg/cache" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -const ( - cachePromoRuleIdPrefix = "cache:promo_rule:id:" - cacheSubscribePromoIdPrefix = "cache:subscribe_promo:id:" -) - -type Model interface { - InsertRule(ctx context.Context, data *Rule) error - FindRule(ctx context.Context, id int64) (*Rule, error) - UpdateRule(ctx context.Context, data *Rule) error - DeleteRule(ctx context.Context, id int64) error - QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) - UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error - FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) - DeletePrice(ctx context.Context, id int64) error - QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) - QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) - Transaction(ctx context.Context, fn func(db *gorm.DB) error) error -} - -type UsageFilter struct { - Page int - Size int - RuleId int64 - UserId int64 - SubscribeId int64 - OrderNo string -} - -type customPromoModel struct { - cache.CachedConn - table string -} - -func NewModel(db *gorm.DB, c *redis.Client) Model { - return &customPromoModel{ - CachedConn: cache.NewConn(db, c), - table: "`promo_rule`", - } -} - -func (m *customPromoModel) ruleCacheKey(id int64) string { - return fmt.Sprintf("%s%d", cachePromoRuleIdPrefix, id) -} - -func (m *customPromoModel) priceCacheKey(id int64) string { - return fmt.Sprintf("%s%d", cacheSubscribePromoIdPrefix, id) -} - -func (m *customPromoModel) InsertRule(ctx context.Context, data *Rule) error { - return m.ExecCtx(ctx, func(conn *gorm.DB) error { - return conn.Create(data).Error - }, m.ruleCacheKey(data.Id)) -} - -func (m *customPromoModel) FindRule(ctx context.Context, id int64) (*Rule, error) { - var resp Rule - err := m.QueryCtx(ctx, &resp, m.ruleCacheKey(id), func(conn *gorm.DB, v interface{}) error { - return conn.Model(&Rule{}).Where("id = ?", id).First(v).Error - }) - if err != nil { - return nil, err - } - return &resp, nil -} - -func (m *customPromoModel) UpdateRule(ctx context.Context, data *Rule) error { - old, err := m.FindRule(ctx, data.Id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - keys := []string{m.ruleCacheKey(data.Id)} - if old != nil { - keys = append(keys, m.ruleCacheKey(old.Id)) - } - return m.ExecCtx(ctx, func(conn *gorm.DB) error { - return conn.Save(data).Error - }, keys...) -} - -func (m *customPromoModel) DeleteRule(ctx context.Context, id int64) error { - data, err := m.FindRule(ctx, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return err - } - return m.ExecCtx(ctx, func(conn *gorm.DB) error { - return conn.Delete(&Rule{}, id).Error - }, m.ruleCacheKey(data.Id)) -} - -func (m *customPromoModel) QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) { - if page <= 0 { - page = 1 - } - if size <= 0 { - size = 10 - } - var total int64 - var list []*Rule - err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { - db := conn.Model(&Rule{}) - if ruleType != "" { - db = db.Where("type = ?", ruleType) - } - if enabled != nil { - db = db.Where("enabled = ?", *enabled) - } - if search != "" { - db = db.Where("name LIKE ?", "%"+search+"%") - } - if err := db.Count(&total).Error; err != nil { - return err - } - return db.Order("priority DESC").Order("id DESC").Limit(size).Offset((page - 1) * size).Find(v).Error - }) - return total, list, err -} - -func (m *customPromoModel) UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error { - return m.ExecCtx(ctx, func(conn *gorm.DB) error { - for _, item := range items { - if item == nil { - continue - } - item.PromoRuleId = ruleId - var existing SubscribePromo - err := conn.Model(&SubscribePromo{}). - Where("subscribe_id = ? AND quantity = ? AND promo_rule_id = ?", item.SubscribeId, item.Quantity, ruleId). - First(&existing).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if errors.Is(err, gorm.ErrRecordNotFound) { - if err := conn.Create(item).Error; err != nil { - return err - } - continue - } - existing.Quantity = item.Quantity - existing.PromoPrice = item.PromoPrice - if err := conn.Save(&existing).Error; err != nil { - return err - } - } - return nil - }) -} - -func (m *customPromoModel) FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) { - var resp SubscribePromo - err := m.QueryCtx(ctx, &resp, m.priceCacheKey(id), func(conn *gorm.DB, v interface{}) error { - return conn.Model(&SubscribePromo{}).Where("id = ?", id).First(v).Error - }) - if err != nil { - return nil, err - } - return &resp, nil -} - -func (m *customPromoModel) DeletePrice(ctx context.Context, id int64) error { - data, err := m.FindPrice(ctx, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return err - } - return m.ExecCtx(ctx, func(conn *gorm.DB) error { - return conn.Delete(&SubscribePromo{}, id).Error - }, m.priceCacheKey(data.Id)) -} - -func (m *customPromoModel) QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) { - if page <= 0 { - page = 1 - } - if size <= 0 { - size = 10 - } - var total int64 - var list []*SubscribePromo - err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { - db := conn.Model(&SubscribePromo{}).Where("promo_rule_id = ?", ruleId) - if err := db.Count(&total).Error; err != nil { - return err - } - return db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(v).Error - }) - return total, list, err -} - -func (m *customPromoModel) QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) { - if params.Page <= 0 { - params.Page = 1 - } - if params.Size <= 0 { - params.Size = 10 - } - var total int64 - var list []*Usage - err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { - db := conn.Model(&Usage{}) - if params.RuleId > 0 { - db = db.Where("promo_rule_id = ?", params.RuleId) - } - if params.UserId > 0 { - db = db.Where("user_id = ?", params.UserId) - } - if params.SubscribeId > 0 { - db = db.Where("subscribe_id = ?", params.SubscribeId) - } - if params.OrderNo != "" { - db = db.Where("order_no = ?", params.OrderNo) - } - if err := db.Count(&total).Error; err != nil { - return err - } - return db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(v).Error - }) - return total, list, err -} - -func (m *customPromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { - return m.TransactCtx(ctx, fn) -} diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go new file mode 100644 index 0000000..c1cc682 --- /dev/null +++ b/internal/model/promo/model.go @@ -0,0 +1,204 @@ +package promo + +import ( + "context" + "errors" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type RuleWithPrice struct { + Rule + PromoPrice int64 `gorm:"column:promo_price"` +} + +type Model interface { + QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) + InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error + InsertRule(ctx context.Context, data *Rule) error + FindRule(ctx context.Context, id int64) (*Rule, error) + UpdateRule(ctx context.Context, data *Rule) error + DeleteRule(ctx context.Context, id int64) error + QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) + UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error + FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) + DeletePrice(ctx context.Context, id int64) error + QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) + QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) + Transaction(ctx context.Context, fn func(db *gorm.DB) error) error +} + +type UsageFilter struct { + Page int + Size int + RuleId int64 + UserId int64 + SubscribeId int64 + OrderNo string +} + +type defaultPromoModel struct { + db *gorm.DB +} + +func NewModel(db *gorm.DB, _ *redis.Client) Model { + return &defaultPromoModel{db: db} +} + +func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) { + var list []*RuleWithPrice + err := m.db.WithContext(ctx). + Table("promo_rule AS pr"). + Select("pr.*, sp.promo_price"). + Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id"). + Where("sp.subscribe_id = ? AND sp.quantity = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, quantity, true). + Where("pr.deleted_at IS NULL"). + Order("pr.priority DESC"). + Order("pr.id ASC"). + Find(&list).Error + return list, err +} + +func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { + db := m.db.WithContext(ctx) + if len(tx) > 0 { + db = tx[0].WithContext(ctx) + } + return db.Model(&Usage{}).Create(data).Error +} + +func (m *defaultPromoModel) InsertRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Create(data).Error +} + +func (m *defaultPromoModel) FindRule(ctx context.Context, id int64) (*Rule, error) { + var resp Rule + if err := m.db.WithContext(ctx).Model(&Rule{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) UpdateRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Save(data).Error +} + +func (m *defaultPromoModel) DeleteRule(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&Rule{}, id).Error +} + +func (m *defaultPromoModel) QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + var total int64 + var list []*Rule + db := m.db.WithContext(ctx).Model(&Rule{}) + if ruleType != "" { + db = db.Where("type = ?", ruleType) + } + if enabled != nil { + db = db.Where("enabled = ?", *enabled) + } + if search != "" { + db = db.Where("name LIKE ?", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("priority DESC").Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range items { + if item == nil { + continue + } + item.PromoRuleId = ruleId + var existing SubscribePromo + err := tx.Model(&SubscribePromo{}). + Where("subscribe_id = ? AND quantity = ? AND promo_rule_id = ?", item.SubscribeId, item.Quantity, ruleId). + First(&existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(item).Error + } + existing.Quantity = item.Quantity + existing.PromoPrice = item.PromoPrice + if err := tx.Save(&existing).Error; err != nil { + return err + } + } + return nil + }) +} + +func (m *defaultPromoModel) FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) { + var resp SubscribePromo + if err := m.db.WithContext(ctx).Model(&SubscribePromo{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) DeletePrice(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&SubscribePromo{}, id).Error +} + +func (m *defaultPromoModel) QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + var total int64 + var list []*SubscribePromo + db := m.db.WithContext(ctx).Model(&SubscribePromo{}).Where("promo_rule_id = ?", ruleId) + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.Size <= 0 { + params.Size = 10 + } + var total int64 + var list []*Usage + db := m.db.WithContext(ctx).Model(&Usage{}) + if params.RuleId > 0 { + db = db.Where("promo_rule_id = ?", params.RuleId) + } + if params.UserId > 0 { + db = db.Where("user_id = ?", params.UserId) + } + if params.SubscribeId > 0 { + db = db.Where("subscribe_id = ?", params.SubscribeId) + } + if params.OrderNo != "" { + db = db.Where("order_no = ?", params.OrderNo) + } + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { + return m.db.WithContext(ctx).Transaction(fn) +} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go index d312396..5218d29 100644 --- a/internal/model/promo/promo.go +++ b/internal/model/promo/promo.go @@ -6,13 +6,19 @@ import ( "gorm.io/gorm" ) +const ( + RuleTypeNewUser = "new_user" + RuleTypeInactiveUser = "inactive_user" + RuleTypeCampaign = "campaign" +) + type Rule struct { Id int64 `gorm:"primaryKey"` - Name string `gorm:"type:varchar(100);not null;default:'';comment:Promo Rule Name"` - Type string `gorm:"type:varchar(32);not null;default:'';comment:Promo Rule Type"` + Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Name"` + Type string `gorm:"type:varchar(32);not null;default:'';comment:Rule Type"` Params string `gorm:"type:json;not null;comment:Rule Params"` Priority int64 `gorm:"type:int;not null;default:0;comment:Priority"` - Enabled *bool `gorm:"type:tinyint(1);not null;default:1;comment:Enabled"` + Enabled bool `gorm:"type:tinyint(1);not null;default:1;comment:Enabled"` StartTime *time.Time `gorm:"default:null;comment:Start Time"` EndTime *time.Time `gorm:"default:null;comment:End Time"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` @@ -20,34 +26,34 @@ type Rule struct { DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` } -func (*Rule) TableName() string { +func (Rule) TableName() string { return "promo_rule" } type SubscribePromo struct { Id int64 `gorm:"primaryKey"` - SubscribeId int64 `gorm:"not null;index:idx_subscribe_qty_rule,unique;comment:Subscribe ID"` - Quantity int64 `gorm:"not null;default:0;index:idx_subscribe_qty_rule,unique;comment:Quantity"` - PromoRuleId int64 `gorm:"not null;index:idx_subscribe_qty_rule,unique;index:idx_promo_rule_id;comment:Promo Rule ID"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } -func (*SubscribePromo) TableName() string { +func (SubscribePromo) TableName() string { return "subscribe_promo" } type Usage struct { Id int64 `gorm:"primaryKey"` - UserId int64 `gorm:"not null;index:idx_user_rule;comment:User ID"` - PromoRuleId int64 `gorm:"not null;index:idx_user_rule;comment:Promo Rule ID"` - SubscribeId int64 `gorm:"not null;comment:Subscribe ID"` - OrderNo string `gorm:"type:varchar(255);not null;default:'';index:idx_order_no;comment:Order No"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:User ID"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';comment:Order No"` PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` } -func (*Usage) TableName() string { +func (Usage) TableName() string { return "promo_usage" } diff --git a/internal/types/types.go b/internal/types/types.go index 31c21ed..dbd3e17 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -828,13 +828,7 @@ type FileUploadRequest struct { } type FileUploadResponse struct { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } type FileUploadCompleteRequest struct { @@ -842,12 +836,7 @@ type FileUploadCompleteRequest struct { } type FileUploadCompleteResponse struct { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } type FileUploadInitRequest struct { @@ -1959,6 +1948,8 @@ type Order struct { Amount int64 `json:"amount"` GiftAmount int64 `json:"gift_amount"` Discount int64 `json:"discount"` + PromoRuleId int64 `json:"promo_rule_id"` + PromoDiscount int64 `json:"promo_discount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` Commission int64 `json:"commission,omitempty"` @@ -1982,6 +1973,8 @@ type OrderDetail struct { Amount int64 `json:"amount"` GiftAmount int64 `json:"gift_amount"` Discount int64 `json:"discount"` + PromoRuleId int64 `json:"promo_rule_id"` + PromoDiscount int64 `json:"promo_discount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` Commission int64 `json:"commission,omitempty"` @@ -2103,6 +2096,7 @@ type PreOrderResponse struct { Price int64 `json:"price"` Amount int64 `json:"amount"` Discount int64 `json:"discount"` + PromoDiscount int64 `json:"promo_discount"` GiftAmount int64 `json:"gift_amount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` @@ -2871,6 +2865,13 @@ type StripePayment struct { PublishableKey string `json:"publishable_key"` } +type SubscribePromo struct { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` +} + type Subscribe struct { Id int64 `json:"id"` Name string `json:"name"` @@ -2879,6 +2880,7 @@ type Subscribe struct { UnitPrice int64 `json:"unit_price"` UnitTime string `json:"unit_time"` Discount []SubscribeDiscount `json:"discount"` + Promo *SubscribePromo `json:"promo"` NodeCount int64 `json:"node_count"` Replacement int64 `json:"replacement"` Inventory int64 `json:"inventory"` diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 8b20137..955d502 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/redemption" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/user" @@ -148,6 +149,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return err } + l.recordPromoUsage(ctx, orderInfo) l.finalizeCouponAndOrder(ctx, orderInfo) commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished", @@ -157,6 +159,44 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return nil } +func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *order.Order) { + if orderInfo == nil || orderInfo.PromoRuleId <= 0 || orderInfo.Quantity <= 0 || orderInfo.SubscribeId <= 0 || orderInfo.OrderNo == "" { + return + } + + promoPrice := int64(0) + if orderInfo.Price > orderInfo.PromoDiscount { + promoPrice = (orderInfo.Price - orderInfo.PromoDiscount) / orderInfo.Quantity + } + if promoPrice <= 0 { + return + } + + err := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if e := tx.Model(&promo.Usage{}).Where("order_no = ?", orderInfo.OrderNo).Count(&count).Error; e != nil { + return e + } + if count > 0 { + return nil + } + return l.svc.PromoModel.InsertUsage(ctx, &promo.Usage{ + UserId: orderInfo.UserId, + PromoRuleId: orderInfo.PromoRuleId, + SubscribeId: orderInfo.SubscribeId, + OrderNo: orderInfo.OrderNo, + PromoPrice: promoPrice, + }, tx) + }) + if err != nil { + logger.WithContext(ctx).Error("Insert promo usage failed", + logger.Field("error", err.Error()), + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("promo_rule_id", orderInfo.PromoRuleId), + ) + } +} + // parsePayload unMarshals the task payload into a structured format func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*queueTypes.ForthwithActivateOrderPayload, error) { var p queueTypes.ForthwithActivateOrderPayload diff --git a/scripts/convert_recovery_orders/main.go b/scripts/convert_recovery_orders/main.go index e2d0e23..2eb6cdc 100644 --- a/scripts/convert_recovery_orders/main.go +++ b/scripts/convert_recovery_orders/main.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/scripts/doc.go b/scripts/doc.go new file mode 100644 index 0000000..b63d60a --- /dev/null +++ b/scripts/doc.go @@ -0,0 +1,3 @@ +// Package scripts keeps standalone maintenance tools out of normal package +// builds. Run individual tools with go run scripts/.go. +package scripts diff --git a/scripts/reconcile_mihapay_orders/main.go b/scripts/reconcile_mihapay_orders/main.go index aba8fb3..5e541f7 100644 --- a/scripts/reconcile_mihapay_orders/main.go +++ b/scripts/reconcile_mihapay_orders/main.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import (