8 Commits

Author SHA1 Message Date
a0b0be2b8e 实现定时通道过期清理 2026-05-08 09:33:41 +08:00
8fc1d30578 优化白名单与通道提取功能 2026-05-07 12:43:15 +08:00
a4d9c28702 实现已发放优惠券的管理接口 2026-04-29 16:59:14 +08:00
ccb8db555e 放开提取接口权限 2026-04-28 18:00:24 +08:00
e70f2337cb 发放优惠券 2026-04-27 17:13:06 +08:00
d59f4ca37f 用户余额查询 2026-04-25 14:15:37 +08:00
0edc883084 用户修改套餐 ip 检查功能接口 2026-04-23 13:47:22 +08:00
d26106eb00 修复运行边界条件问题 2026-04-22 17:11:55 +08:00
23 changed files with 1023 additions and 111 deletions

View File

@@ -1,8 +1,16 @@
## TODO ## TODO
proxy 的删除和更新,锁粒度应该有问题 - 选择代理部分,可以检查 redis 中的可用端口数量,无需查数据库
- 取用端口后直接写入关闭任务,避免中途失败导致端口泄漏
-
最低价格 0.01 ---
错误提示增强,展示整链路信息
ip 提取频率限制,在 ensure 函数加逻辑,通过 redis 或者 pg 计算分钟内提取次数,只允许每分钟提取 30 次
proxy 的删除和更新,锁粒度应该有问题
交易信息持久化 交易信息持久化

View File

@@ -127,7 +127,8 @@ insert into permission (name, description, sort) values
('trade', '交易', 12), ('trade', '交易', 12),
('bill', '账单', 13), ('bill', '账单', 13),
('balance_activity', '余额变动', 14), ('balance_activity', '余额变动', 14),
('proxy', '代理', 15); ('proxy', '代理', 15),
('coupon_user', '已发放优惠券', 16);
-- -------------------------- -- --------------------------
-- level 2 -- level 2
@@ -136,79 +137,84 @@ insert into permission (name, description, sort) values
-- permission 子权限 -- permission 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'permission' and deleted_at is null), 'permission:read', '读取权限列表', 1), ((select id from permission where name = 'permission' and deleted_at is null), 'permission:read', '读取权限列表', 1),
((select id from permission where name = 'permission' and deleted_at is null), 'permission:write', '写入权限', 2); ((select id from permission where name = 'permission' and deleted_at is null), 'permission:write', '编辑权限', 2);
-- admin_role 子权限 -- admin_role 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'admin_role' and deleted_at is null), 'admin_role:read', '读取管理员角色列表', 1), ((select id from permission where name = 'admin_role' and deleted_at is null), 'admin_role:read', '读取管理员角色列表', 1),
((select id from permission where name = 'admin_role' and deleted_at is null), 'admin_role:write', '写入管理员角色', 2); ((select id from permission where name = 'admin_role' and deleted_at is null), 'admin_role:write', '编辑管理员角色', 2);
-- admin 子权限 -- admin 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'admin' and deleted_at is null), 'admin:read', '读取管理员列表', 1), ((select id from permission where name = 'admin' and deleted_at is null), 'admin:read', '读取管理员列表', 1),
((select id from permission where name = 'admin' and deleted_at is null), 'admin:write', '写入管理员', 2); ((select id from permission where name = 'admin' and deleted_at is null), 'admin:write', '编辑管理员', 2);
-- product 子权限 -- product 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'product' and deleted_at is null), 'product:read', '读取产品列表', 1), ((select id from permission where name = 'product' and deleted_at is null), 'product:read', '读取产品列表', 1),
((select id from permission where name = 'product' and deleted_at is null), 'product:write', '写入产品', 2); ((select id from permission where name = 'product' and deleted_at is null), 'product:write', '编辑产品', 2);
-- product_sku 子权限 -- product_sku 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'product_sku' and deleted_at is null), 'product_sku:read', '读取产品套餐列表', 1), ((select id from permission where name = 'product_sku' and deleted_at is null), 'product_sku:read', '读取产品套餐列表', 1),
((select id from permission where name = 'product_sku' and deleted_at is null), 'product_sku:write', '写入产品套餐', 2); ((select id from permission where name = 'product_sku' and deleted_at is null), 'product_sku:write', '编辑产品套餐', 2);
-- discount 子权限 -- discount 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'discount' and deleted_at is null), 'discount:read', '读取折扣列表', 1), ((select id from permission where name = 'discount' and deleted_at is null), 'discount:read', '读取折扣列表', 1),
((select id from permission where name = 'discount' and deleted_at is null), 'discount:write', '写入折扣', 2); ((select id from permission where name = 'discount' and deleted_at is null), 'discount:write', '编辑折扣', 2);
-- resource 子权限 -- resource 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'resource' and deleted_at is null), 'resource:read', '读取用户套餐列表', 1), ((select id from permission where name = 'resource' and deleted_at is null), 'resource:read', '读取用户套餐列表', 1),
((select id from permission where name = 'resource' and deleted_at is null), 'resource:write', '写入用户套餐', 2), ((select id from permission where name = 'resource' and deleted_at is null), 'resource:write', '编辑用户套餐', 2),
((select id from permission where name = 'resource' and deleted_at is null), 'resource:short', '短效动态套餐', 3), ((select id from permission where name = 'resource' and deleted_at is null), 'resource:short', '短效动态套餐', 3),
((select id from permission where name = 'resource' and deleted_at is null), 'resource:long', '长效动态套餐', 4); ((select id from permission where name = 'resource' and deleted_at is null), 'resource:long', '长效动态套餐', 4);
-- user 子权限 -- user 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'user' and deleted_at is null), 'user:read', '读取用户列表', 1), ((select id from permission where name = 'user' and deleted_at is null), 'user:read', '读取用户列表', 1),
((select id from permission where name = 'user' and deleted_at is null), 'user:write', '写入用户', 2); ((select id from permission where name = 'user' and deleted_at is null), 'user:write', '编辑用户', 2);
-- coupon 子权限 -- coupon 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon' and deleted_at is null), 'coupon:read', '读取优惠券列表', 1), ((select id from permission where name = 'coupon' and deleted_at is null), 'coupon:read', '读取优惠券列表', 1),
((select id from permission where name = 'coupon' and deleted_at is null), 'coupon:write', '写入优惠券', 2); ((select id from permission where name = 'coupon' and deleted_at is null), 'coupon:write', '编辑优惠券', 2);
-- batch 子权限 -- batch 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'batch' and deleted_at is null), 'batch:read', '读取批次列表', 1), ((select id from permission where name = 'batch' and deleted_at is null), 'batch:read', '读取批次列表', 1),
((select id from permission where name = 'batch' and deleted_at is null), 'batch:write', '写入批次', 2); ((select id from permission where name = 'batch' and deleted_at is null), 'batch:write', '编辑批次', 2);
-- channel 子权限 -- channel 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'channel' and deleted_at is null), 'channel:read', '读取 IP 列表', 1), ((select id from permission where name = 'channel' and deleted_at is null), 'channel:read', '读取 IP 列表', 1),
((select id from permission where name = 'channel' and deleted_at is null), 'channel:write', '写入 IP', 2); ((select id from permission where name = 'channel' and deleted_at is null), 'channel:write', '编辑 IP', 2);
-- proxy 子权限 -- proxy 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'proxy' and deleted_at is null), 'proxy:read', '读取代理列表', 1), ((select id from permission where name = 'proxy' and deleted_at is null), 'proxy:read', '读取代理列表', 1),
((select id from permission where name = 'proxy' and deleted_at is null), 'proxy:write', '写入代理', 2); ((select id from permission where name = 'proxy' and deleted_at is null), 'proxy:write', '编辑代理', 2);
-- trade 子权限 -- trade 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'trade' and deleted_at is null), 'trade:read', '读取交易列表', 1), ((select id from permission where name = 'trade' and deleted_at is null), 'trade:read', '读取交易列表', 1),
((select id from permission where name = 'trade' and deleted_at is null), 'trade:write', '写入交易', 2); ((select id from permission where name = 'trade' and deleted_at is null), 'trade:write', '编辑交易', 2);
-- bill 子权限 -- bill 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'bill' and deleted_at is null), 'bill:read', '读取账单列表', 1), ((select id from permission where name = 'bill' and deleted_at is null), 'bill:read', '读取账单列表', 1),
((select id from permission where name = 'bill' and deleted_at is null), 'bill:write', '写入账单', 2); ((select id from permission where name = 'bill' and deleted_at is null), 'bill:write', '编辑账单', 2);
-- balance_activity 子权限 -- balance_activity 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'balance_activity' and deleted_at is null), 'balance_activity:read', '读取余额变动列表', 1); ((select id from permission where name = 'balance_activity' and deleted_at is null), 'balance_activity:read', '读取余额变动列表', 1);
-- coupon_user 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:read', '读取已发放优惠券列表', 1),
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:write', '编辑已发放优惠券', 2);
-- -------------------------- -- --------------------------
-- level 3 -- level 3
-- -------------------------- -- --------------------------
@@ -236,7 +242,7 @@ insert into permission (parent_id, name, description, sort) values
-- user:write 子权限 -- user:write 子权限
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'user:write' and deleted_at is null), 'user:write:balance', '写入用户余额', 1), ((select id from permission where name = 'user:write' and deleted_at is null), 'user:write:balance', '编辑用户余额', 1),
((select id from permission where name = 'user:write' and deleted_at is null), 'user:write:bind', '用户认领', 2); ((select id from permission where name = 'user:write' and deleted_at is null), 'user:write:bind', '用户认领', 2);
-- batch:read 子权限 -- batch:read 子权限
@@ -259,6 +265,14 @@ insert into permission (parent_id, name, description, sort) values
insert into permission (parent_id, name, description, sort) values insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'balance_activity:read' and deleted_at is null), 'balance_activity:read:of_user', '读取指定用户的余额变动列表', 1); ((select id from permission where name = 'balance_activity:read' and deleted_at is null), 'balance_activity:read:of_user', '读取指定用户的余额变动列表', 1);
-- coupon:write 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon:write' and deleted_at is null), 'coupon:write:assign', '发放优惠券', 1);
-- coupon_user:read 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user:read' and deleted_at is null), 'coupon_user:read:of_user', '读取指定用户的已发放优惠券列表', 1);
-- -------------------------- -- --------------------------
-- level 4 -- level 4
-- -------------------------- -- --------------------------

View File

@@ -1111,7 +1111,7 @@ comment on table coupon_user is '优惠券发放表';
comment on column coupon_user.id is '记录ID'; comment on column coupon_user.id is '记录ID';
comment on column coupon_user.coupon_id is '优惠券ID'; comment on column coupon_user.coupon_id is '优惠券ID';
comment on column coupon_user.user_id is '用户ID'; comment on column coupon_user.user_id is '用户ID';
comment on column coupon_user.status is '使用状态0-未使用1-已使用'; comment on column coupon_user.status is '使用状态0-未使用1-已使用2-已禁用';
comment on column coupon_user.expire_at is '过期时间'; comment on column coupon_user.expire_at is '过期时间';
comment on column coupon_user.used_at is '使用时间'; comment on column coupon_user.used_at is '使用时间';
comment on column coupon_user.created_at is '创建时间'; comment on column coupon_user.created_at is '创建时间';

View File

@@ -118,7 +118,7 @@ func Query(in any) url.Values {
case int: case int:
out.Add(name, strconv.Itoa(value)) out.Add(name, strconv.Itoa(value))
case bool: case bool:
if tags[1] == "b2i" { if len(tags) > 1 && tags[1] == "b2i" {
out.Add(name, u.Ternary(value, "1", "0")) out.Add(name, u.Ternary(value, "1", "0"))
} else { } else {
out.Add(name, strconv.FormatBool(value)) out.Add(name, strconv.FormatBool(value))

View File

@@ -48,9 +48,15 @@ const (
ScopeUserWriteBalanceDec = string("user:write:balance:dec") // 减少用户余额 ScopeUserWriteBalanceDec = string("user:write:balance:dec") // 减少用户余额
ScopeUserWriteBind = string("user:write:bind") // 用户认领 ScopeUserWriteBind = string("user:write:bind") // 用户认领
ScopeCoupon = string("coupon") // 优惠券 ScopeCoupon = string("coupon") // 优惠券
ScopeCouponRead = string("coupon:read") // 读取优惠券列表 ScopeCouponRead = string("coupon:read") // 读取优惠券列表
ScopeCouponWrite = string("coupon:write") // 写入优惠券 ScopeCouponWrite = string("coupon:write") // 写入优惠券
ScopeCouponWriteAssign = string("coupon:write:assign") // 发放优惠券
ScopeCouponUser = string("coupon_user") // 用户优惠券
ScopeCouponUserRead = string("coupon_user:read") // 读取用户优惠券列表
ScopeCouponUserReadOfUser = string("coupon_user:read:of_user") // 读取指定用户的用户优惠券列表
ScopeCouponUserWrite = string("coupon_user:write") // 写入用户优惠券
ScopeBatch = string("batch") // 批次 ScopeBatch = string("batch") // 批次
ScopeBatchRead = string("batch:read") // 读取批次列表 ScopeBatchRead = string("batch:read") // 读取批次列表

View File

@@ -79,7 +79,6 @@ func ErrorHandler(c *fiber.Ctx, err error) error {
slog.Warn("未处理的异常", slog.String("type", t.String()), slog.String("error", err.Error())) slog.Warn("未处理的异常", slog.String("type", t.String()), slog.String("error", err.Error()))
} }
slog.Warn(message)
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
return c.Status(code).SendString(message) return c.Status(code).SendString(message)
} }

View File

@@ -9,3 +9,9 @@ const RemoveChannel = "channel:remove"
func NewRemoveChannel(batch string) *asynq.Task { func NewRemoveChannel(batch string) *asynq.Task {
return asynq.NewTask(RemoveChannel, []byte(batch)) return asynq.NewTask(RemoveChannel, []byte(batch))
} }
const ClearExpiredChannels = "channel:clear_expired"
func NewClearExpiredChannels() *asynq.Task {
return asynq.NewTask(ClearExpiredChannels, nil)
}

View File

@@ -11,6 +11,65 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// PageBalanceActivity 分页查询当前用户的余额变动记录
func PageBalanceActivity(c *fiber.Ctx) error {
// 获取当前用户ID
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
// 解析请求参数
req := new(PageBalanceActivityByUserReq)
if err := g.Validator.ParseBody(c, req); err != nil {
return err
}
// 构造查询条件
do := q.BalanceActivity.Where(q.BalanceActivity.UserID.Eq(authCtx.User.ID))
if req.BillNo != nil {
do = do.Where(q.Bill.As("Bill").BillNo.Eq(*req.BillNo))
}
if req.CreatedAtStart != nil {
t := u.DateHead(*req.CreatedAtStart)
do = do.Where(q.BalanceActivity.CreatedAt.Gte(t))
}
if req.CreatedAtEnd != nil {
t := u.DateTail(*req.CreatedAtEnd)
do = do.Where(q.BalanceActivity.CreatedAt.Lte(t))
}
// 查询余额变动列表
list, total, err := q.BalanceActivity.
Joins(q.BalanceActivity.Bill).
Select(
q.BalanceActivity.ALL,
q.Bill.As("Bill").ID.As("Bill__id"),
q.Bill.As("Bill").BillNo.As("Bill__bill_no"),
).
Where(do).
Order(q.BalanceActivity.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
// 返回结果
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageBalanceActivityByUserReq struct {
core.PageReq
BillNo *string `json:"bill_no,omitempty"`
CreatedAtStart *time.Time `json:"created_at_start,omitempty"`
CreatedAtEnd *time.Time `json:"created_at_end,omitempty"`
}
// PageBalanceActivityByAdmin 分页查询所有余额变动记录 // PageBalanceActivityByAdmin 分页查询所有余额变动记录
func PageBalanceActivityByAdmin(c *fiber.Ctx) error { func PageBalanceActivityByAdmin(c *fiber.Ctx) error {
// 检查权限 // 检查权限

View File

@@ -172,12 +172,7 @@ type ListChannelsReq struct {
// CreateChannel 创建新通道 // CreateChannel 创建新通道
func CreateChannel(c *fiber.Ctx) error { func CreateChannel(c *fiber.Ctx) error {
// 不检查权限,允许 api 调用
// 检查权限
_, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
// 解析参数 // 解析参数
req := new(CreateChannelReq) req := new(CreateChannelReq)
@@ -217,6 +212,7 @@ func CreateChannel(c *fiber.Ctx) error {
resp[i] = &CreateChannelRespItem{ resp[i] = &CreateChannelRespItem{
Proto: req.Protocol, Proto: req.Protocol,
Host: channel.Host, Host: channel.Host,
IP: channel.Proxy.IP.String(),
Port: channel.Port, Port: channel.Port,
} }
if req.AuthType == s.ChannelAuthTypePass { if req.AuthType == s.ChannelAuthTypePass {
@@ -240,6 +236,7 @@ type CreateChannelReq struct {
type CreateChannelRespItem struct { type CreateChannelRespItem struct {
Proto int `json:"-"` Proto int `json:"-"`
Host string `json:"host"` Host string `json:"host"`
IP string `json:"ip"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Username *string `json:"username,omitempty"` Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"` Password *string `json:"password,omitempty"`

View File

@@ -103,3 +103,27 @@ func DeleteCoupon(c *fiber.Ctx) error {
return nil return nil
} }
func AssignCoupon(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponWriteAssign)
if err != nil {
return err
}
var req AssignCouponReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
err = s.Coupon.Assign(req.CouponID, req.UserID)
if err != nil {
return err
}
return nil
}
type AssignCouponReq struct {
CouponID int32 `json:"coupon_id" validate:"required"`
UserID int32 `json:"user_id" validate:"required"`
}

330
web/handlers/coupon_user.go Normal file
View File

@@ -0,0 +1,330 @@
package handlers
import (
"errors"
"platform/pkg/u"
"platform/web/auth"
"platform/web/core"
g "platform/web/globals"
m "platform/web/models"
q "platform/web/queries"
s "platform/web/services"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/gorm"
)
// PageCouponUser 分页查询当前用户已发放优惠券
func PageCouponUser(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
var req PageCouponUserReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
conds = append(conds, q.CouponUser.UserID.Eq(authCtx.User.ID))
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon).
Select(couponUserSelect(false)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserReq struct {
core.PageReq
CouponUserPageFilter
}
// GetCouponUser 获取当前用户已发放优惠券详情
func GetCouponUser(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
item, err := q.CouponUser.
Joins(q.CouponUser.Coupon).
Select(couponUserSelect(false)...).
Where(
q.CouponUser.ID.Eq(req.Id),
q.CouponUser.UserID.Eq(authCtx.User.ID),
).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(item)
}
// PageCouponUserByAdmin 分页查询全部已发放优惠券
func PageCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserRead)
if err != nil {
return err
}
var req PageCouponUserByAdminReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
if req.UserID != nil {
conds = append(conds, q.CouponUser.UserID.Eq(*req.UserID))
}
if req.UserPhone != nil {
conds = append(conds, q.User.As("User").Phone.Eq(*req.UserPhone))
}
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserByAdminReq struct {
core.PageReq
CouponUserPageFilter
UserID *int32 `json:"user_id,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
}
// PageCouponUserOfUserByAdmin 分页查询指定用户已发放优惠券
func PageCouponUserOfUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserReadOfUser)
if err != nil {
return err
}
var req PageCouponUserOfUserByAdminReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
conds = append(conds, q.CouponUser.UserID.Eq(req.UserID))
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserOfUserByAdminReq struct {
core.PageReq
CouponUserPageFilter
UserID int32 `json:"user_id" validate:"required"`
}
// GetCouponUserByAdmin 获取已发放优惠券详情
func GetCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserRead)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
item, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(q.CouponUser.ID.Eq(req.Id)).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(item)
}
func CreateCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req s.CreateCouponUserData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Create(req); err != nil {
return err
}
return nil
}
func UpdateCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req s.UpdateCouponUserData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Update(req); err != nil {
return err
}
return nil
}
func DeleteCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Delete(req.Id); err != nil {
return err
}
return nil
}
type CouponUserPageFilter struct {
CouponID *int32 `json:"coupon_id,omitempty"`
CouponName *string `json:"coupon_name,omitempty"`
Status *m.CouponUserStatus `json:"status,omitempty"`
Expired *bool `json:"expired,omitempty"`
CreatedAtStart *time.Time `json:"created_at_start,omitempty"`
CreatedAtEnd *time.Time `json:"created_at_end,omitempty"`
ExpireAtStart *time.Time `json:"expire_at_start,omitempty"`
ExpireAtEnd *time.Time `json:"expire_at_end,omitempty"`
UsedAtStart *time.Time `json:"used_at_start,omitempty"`
UsedAtEnd *time.Time `json:"used_at_end,omitempty"`
}
func couponUserPageConditions(req CouponUserPageFilter) []gen.Condition {
conds := make([]gen.Condition, 0)
if req.CouponID != nil {
conds = append(conds, q.CouponUser.CouponID.Eq(*req.CouponID))
}
if req.CouponName != nil {
conds = append(conds, q.Coupon.As("Coupon").Name.Like("%"+*req.CouponName+"%"))
}
if req.Status != nil {
conds = append(conds, q.CouponUser.Status.Eq(int(*req.Status)))
}
if req.Expired != nil {
if *req.Expired {
conds = append(conds, q.CouponUser.ExpireAt.IsNotNull(), q.CouponUser.ExpireAt.Lte(time.Now()))
} else {
conds = append(conds, q.CouponUser.Where(q.CouponUser.ExpireAt.IsNull()).Or(q.CouponUser.ExpireAt.Gt(time.Now())))
}
}
if req.CreatedAtStart != nil {
conds = append(conds, q.CouponUser.CreatedAt.Gte(u.DateHead(*req.CreatedAtStart)))
}
if req.CreatedAtEnd != nil {
conds = append(conds, q.CouponUser.CreatedAt.Lte(u.DateTail(*req.CreatedAtEnd)))
}
if req.ExpireAtStart != nil {
conds = append(conds, q.CouponUser.ExpireAt.Gte(u.DateHead(*req.ExpireAtStart)))
}
if req.ExpireAtEnd != nil {
conds = append(conds, q.CouponUser.ExpireAt.Lte(u.DateTail(*req.ExpireAtEnd)))
}
if req.UsedAtStart != nil {
conds = append(conds, q.CouponUser.UsedAt.Gte(u.DateHead(*req.UsedAtStart)))
}
if req.UsedAtEnd != nil {
conds = append(conds, q.CouponUser.UsedAt.Lte(u.DateTail(*req.UsedAtEnd)))
}
return conds
}
func couponUserSelect(includeUser bool) []field.Expr {
cols := []field.Expr{
q.CouponUser.ALL,
q.Coupon.As("Coupon").ID.As("Coupon__id"),
q.Coupon.As("Coupon").Name.As("Coupon__name"),
q.Coupon.As("Coupon").Amount.As("Coupon__amount"),
q.Coupon.As("Coupon").MinAmount.As("Coupon__min_amount"),
q.Coupon.As("Coupon").Count_.As("Coupon__count"),
q.Coupon.As("Coupon").Status.As("Coupon__status"),
q.Coupon.As("Coupon").ExpireType.As("Coupon__expire_type"),
q.Coupon.As("Coupon").ExpireAt.As("Coupon__expire_at"),
q.Coupon.As("Coupon").ExpireIn.As("Coupon__expire_in"),
q.Coupon.As("Coupon").CreatedAt.As("Coupon__created_at"),
q.Coupon.As("Coupon").UpdatedAt.As("Coupon__updated_at"),
}
if includeUser {
cols = append(cols,
q.User.As("User").ID.As("User__id"),
q.User.As("User").Phone.As("User__phone"),
q.User.As("User").Name.As("User__name"),
)
}
return cols
}

View File

@@ -84,6 +84,7 @@ func PageResourceShort(c *fiber.Ctx) error {
total = int64(len(resource) + req.GetOffset()) total = int64(len(resource) + req.GetOffset())
} else { } else {
total, err = q.Resource. total, err = q.Resource.
Joins(q.Resource.Short).
Where(do). Where(do).
Count() Count()
if err != nil { if err != nil {
@@ -180,6 +181,7 @@ func PageResourceLong(c *fiber.Ctx) error {
total = int64(len(resource) + req.GetOffset()) total = int64(len(resource) + req.GetOffset())
} else { } else {
total, err = q.Resource. total, err = q.Resource.
Joins(q.Resource.Long).
Where(do). Where(do).
Count() Count()
if err != nil { if err != nil {
@@ -609,6 +611,30 @@ func UpdateResourceByAdmin(c *fiber.Ctx) error {
return c.JSON(nil) return c.JSON(nil)
} }
func UpdateResourceCheckIP(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
var req struct {
core.IdReq
CheckIP bool `json:"checkip"`
}
if err := c.BodyParser(&req); err != nil {
return err
}
if err := s.Resource.Update(&s.UpdateResourceData{
IdReq: req.IdReq,
CheckIP: &req.CheckIP,
}); err != nil {
return err
}
return c.JSON(nil)
}
// StatisticResourceFree 统计每日可用 // StatisticResourceFree 统计每日可用
func StatisticResourceFree(c *fiber.Ctx) error { func StatisticResourceFree(c *fiber.Ctx) error {
// 检查权限 // 检查权限

View File

@@ -260,7 +260,7 @@ type UpdateUserBalanceByAdminData struct {
// 绑定管理员 // 绑定管理员
func BindAdmin(c *fiber.Ctx) error { func BindAdmin(c *fiber.Ctx) error {
// 检查权限 // 检查权限
authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeUserWrite) authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeUserWriteBind)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"platform/pkg/env" "platform/pkg/env"
"platform/pkg/u" "platform/pkg/u"
"platform/web/auth" "platform/web/auth"
@@ -97,13 +98,31 @@ func CreateWhitelist(c *fiber.Ctx) error {
} }
// 创建白名单 // 创建白名单
err = q.Whitelist.Create(&m.Whitelist{ uid := authCtx.User.ID
UserID: authCtx.User.ID, err = g.Redsync.WithLock(whitelistKey(uid), func() error {
IP: u.Z(ip), count, err := q.Whitelist.Where(
Remark: &req.Remark, q.Whitelist.UserID.Eq(uid),
).Count()
if err != nil {
return core.NewServErr("获取白名单数量失败", err)
}
if count >= 5 {
return core.NewBizErr("白名单数量已达上限")
}
err = q.Whitelist.Create(&m.Whitelist{
UserID: authCtx.User.ID,
IP: u.Z(ip),
Remark: &req.Remark,
})
if err != nil {
return core.NewServErr("添加白名单失败", err)
}
return nil
}) })
if err != nil { if err != nil {
return core.NewServErr("添加白名单失败", err) return err
} }
return nil return nil
@@ -206,3 +225,7 @@ func secureAddr(str string) (*orm.Inet, error) {
} }
return ip, nil return ip, nil
} }
func whitelistKey(userID int32) string {
return fmt.Sprintf("platform:whitelist:add:%d", userID)
}

View File

@@ -4,13 +4,13 @@ import "time"
// CouponUser 优惠券发放表 // CouponUser 优惠券发放表
type CouponUser struct { type CouponUser struct {
ID int32 `json:"id" gorm:"column:id;primaryKey"` // 记录ID ID int32 `json:"id" gorm:"column:id;primaryKey"` // 记录ID
CouponID int32 `json:"coupon_id" gorm:"column:coupon_id"` // 优惠券ID CouponID int32 `json:"coupon_id" gorm:"column:coupon_id"` // 优惠券ID
UserID int32 `json:"user_id" gorm:"column:user_id"` // 用户ID UserID int32 `json:"user_id" gorm:"column:user_id"` // 用户ID
Status CouponStatus `json:"status" gorm:"column:status"` // 使用状态0-未使用1-已使用 Status CouponUserStatus `json:"status" gorm:"column:status"` // 使用状态0-未使用1-已使用2-已禁用
ExpireAt *time.Time `json:"expire_at,omitempty" gorm:"column:expire_at"` // 过期时间 ExpireAt *time.Time `json:"expire_at,omitempty" gorm:"column:expire_at"` // 过期时间
UsedAt *time.Time `json:"used_at,omitempty" gorm:"column:used_at"` // 使用时间 UsedAt *time.Time `json:"used_at,omitempty" gorm:"column:used_at"` // 使用时间
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` // 创建时间 CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` // 创建时间
Coupon *Coupon `json:"coupon,omitempty" gorm:"foreignKey:CouponID"` Coupon *Coupon `json:"coupon,omitempty" gorm:"foreignKey:CouponID"`
User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` User *User `json:"user,omitempty" gorm:"foreignKey:UserID"`
@@ -20,6 +20,7 @@ type CouponUser struct {
type CouponUserStatus int type CouponUserStatus int
const ( const (
CouponUserStatusUnused CouponUserStatus = 0 // 未使用 CouponUserStatusUnused CouponUserStatus = 0 // 未使用
CouponUserStatusUsed CouponUserStatus = 1 // 已使用 CouponUserStatusUsed CouponUserStatus = 1 // 已使用
CouponUserStatusDisabled CouponUserStatus = 2 // 已禁用
) )

View File

@@ -4,6 +4,7 @@ import (
"platform/pkg/env" "platform/pkg/env"
auth2 "platform/web/auth" auth2 "platform/web/auth"
"platform/web/handlers" "platform/web/handlers"
s "platform/web/services"
"time" "time"
q "platform/web/queries" q "platform/web/queries"
@@ -33,6 +34,12 @@ func ApplyRouters(app *fiber.App) {
} }
return ctx.JSON(rs) return ctx.JSON(rs)
}) })
debug.Get("/channel/clear-expired", func(ctx *fiber.Ctx) error {
if err := s.Channel.ClearExpiredChannels(); err != nil {
return err
}
return ctx.SendStatus(fiber.StatusOK)
})
} }
} }
@@ -66,6 +73,7 @@ func userRouter(api fiber.Router) {
resource.Post("/list/short", handlers.PageResourceShort) resource.Post("/list/short", handlers.PageResourceShort)
resource.Post("/list/long", handlers.PageResourceLong) resource.Post("/list/long", handlers.PageResourceLong)
resource.Post("/create", handlers.CreateResource) resource.Post("/create", handlers.CreateResource)
resource.Post("/update/checkip", handlers.UpdateResourceCheckIP)
resource.Post("/statistics/free", handlers.StatisticResourceFree) resource.Post("/statistics/free", handlers.StatisticResourceFree)
resource.Post("/statistics/usage", handlers.StatisticResourceUsage) resource.Post("/statistics/usage", handlers.StatisticResourceUsage)
@@ -90,6 +98,15 @@ func userRouter(api fiber.Router) {
bill := api.Group("/bill") bill := api.Group("/bill")
bill.Post("/list", handlers.ListBill) bill.Post("/list", handlers.ListBill)
// 余额变动
balance := api.Group("/balance")
balance.Post("/page", handlers.PageBalanceActivity)
// 已发放优惠券
couponUser := api.Group("/coupon-user")
couponUser.Post("/page", handlers.PageCouponUser)
couponUser.Post("/get", handlers.GetCouponUser)
// 公告 // 公告
announcement := api.Group("/announcement") announcement := api.Group("/announcement")
announcement.Post("/list", handlers.ListAnnouncements) announcement.Post("/list", handlers.ListAnnouncements)
@@ -248,4 +265,14 @@ func adminRouter(api fiber.Router) {
coupon.Post("/create", handlers.CreateCoupon) coupon.Post("/create", handlers.CreateCoupon)
coupon.Post("/update", handlers.UpdateCoupon) coupon.Post("/update", handlers.UpdateCoupon)
coupon.Post("/remove", handlers.DeleteCoupon) coupon.Post("/remove", handlers.DeleteCoupon)
coupon.Post("/update/assign", handlers.AssignCoupon)
// coupon-user 已发放优惠券
var couponUser = api.Group("/coupon-user")
couponUser.Post("/page", handlers.PageCouponUserByAdmin)
couponUser.Post("/page/of-user", handlers.PageCouponUserOfUserByAdmin)
couponUser.Post("/get", handlers.GetCouponUserByAdmin)
couponUser.Post("/create", handlers.CreateCouponUserByAdmin)
couponUser.Post("/update", handlers.UpdateCouponUserByAdmin)
couponUser.Post("/remove", handlers.DeleteCouponUserByAdmin)
} }

View File

@@ -26,6 +26,7 @@ var Channel = &channelServer{
type ChannelServiceProvider interface { type ChannelServiceProvider interface {
CreateChannels(source netip.Addr, resourceId int32, authWhitelist bool, authPassword bool, count int, edgeFilter *EdgeFilter) ([]*m.Channel, error) CreateChannels(source netip.Addr, resourceId int32, authWhitelist bool, authPassword bool, count int, edgeFilter *EdgeFilter) ([]*m.Channel, error)
RemoveChannels(batch string) error RemoveChannels(batch string) error
ClearExpiredChannels() error
} }
type channelServer struct { type channelServer struct {
@@ -40,6 +41,10 @@ func (s *channelServer) RemoveChannels(batch string) error {
return s.provider.RemoveChannels(batch) return s.provider.RemoveChannels(batch)
} }
func (s *channelServer) ClearExpiredChannels() error {
return s.provider.ClearExpiredChannels()
}
// 授权方式 // 授权方式
type ChannelAuthType int type ChannelAuthType int
@@ -147,7 +152,7 @@ type ResourceView struct {
} }
// 检查用户是否可提取 // 检查用户是否可提取
func ensure(now time.Time, source netip.Addr, resourceId int32, count int) (*ResourceView, []string, error) { func ensure(now time.Time, source netip.Addr, resourceId int32, authWhitelist bool, count int) (*ResourceView, []string, error) {
if count > 400 { if count > 400 {
return nil, nil, core.NewBizErr("单次最多提取 400 个") return nil, nil, core.NewBizErr("单次最多提取 400 个")
} }
@@ -172,6 +177,10 @@ func ensure(now time.Time, source netip.Addr, resourceId int32, count int) (*Res
return nil, nil, err return nil, nil, err
} }
if authWhitelist && len(whitelists) == 0 {
return nil, nil, core.NewBizErr("当前白名单为空,请先添加白名单")
}
ips := make([]string, len(whitelists)) ips := make([]string, len(whitelists))
pass := false pass := false
for i, item := range whitelists { for i, item := range whitelists {

View File

@@ -17,7 +17,6 @@ import (
"time" "time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
"gorm.io/gen"
"gorm.io/gen/field" "gorm.io/gen/field"
) )
@@ -32,7 +31,7 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
batch := ID.GenReadable("bat") batch := ID.GenReadable("bat")
// 检查并获取套餐与白名单 // 检查并获取套餐与白名单
resource, whitelists, err := ensure(now, source, resourceId, count) resource, whitelists, err := ensure(now, source, resourceId, authWhitelist, count)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -63,7 +62,7 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
} }
proxy := proxyResult.Proxy proxy := proxyResult.Proxy
// 锁内确认状态并锁定端口,避免与状态切换并发穿透 // 取用端口
var chans []netip.AddrPort var chans []netip.AddrPort
err = g.Redsync.WithLock(proxyStatusLockKey(proxy.ID), func() error { err = g.Redsync.WithLock(proxyStatusLockKey(proxy.ID), func() error {
lockedProxy, err := q.Proxy.Where(q.Proxy.ID.Eq(proxy.ID)).Take() lockedProxy, err := q.Proxy.Where(q.Proxy.ID.Eq(proxy.ID)).Take()
@@ -86,30 +85,30 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
return nil, err return nil, err
} }
// 获取可用节点 _, err = g.Asynq.Enqueue(
edgesResp, err := g.Cloud.CloudEdges(&g.CloudEdgesReq{ e.NewRemoveChannel(batch),
Province: filter.Prov, asynq.ProcessAt(expire),
City: filter.City, )
Isp: u.X(filter.Isp.String()),
Limit: &count,
NoRepeat: u.P(true),
NoDayRepeat: u.P(true),
ActiveTime: u.P(3600),
IpUnchangedTime: u.P(3600),
Sort: u.P("ip_unchanged_time_asc"),
})
if err != nil { if err != nil {
return nil, core.NewBizErr("获取可用节点失败", err) return nil, core.NewServErr("提交关闭通道任务失败", err)
} }
if edgesResp.Total != count && len(edgesResp.Edges) != count {
return nil, core.NewBizErr("地区可用节点数量不足")
}
edges := edgesResp.Edges
// 准备通道数据 // 取用节点
secret := strings.Split(u.Z(proxy.Secret), ":")
if len(secret) != 2 {
return nil, core.NewServErr(fmt.Sprintf("代理 %s 密钥格式错误", proxy.IP.String()), nil)
}
gateway := g.NewGateway(proxy.IP.String(), secret[0], secret[1])
edges, err := getAvailableEdges(gateway, filter, count)
if err != nil {
return nil, err
}
// 绑定节点到端口
channels := make([]*m.Channel, count) channels := make([]*m.Channel, count)
chanConfigs := make([]*g.PortConfigsReq, count) chanConfigs := make([]*g.PortConfigsReq, count)
edgeConfigs := make([]string, count) edgeConfigs := make([]string, 0, count)
for i := range count { for i := range count {
ch := chans[i] ch := chans[i]
edge := edges[i] edge := edges[i]
@@ -127,6 +126,7 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
FilterProv: filter.Prov, FilterProv: filter.Prov,
FilterCity: filter.City, FilterCity: filter.City,
ExpiredAt: expire, ExpiredAt: expire,
Proxy: &proxy,
} }
// 通道配置数据 // 通道配置数据
@@ -151,29 +151,18 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
} }
// 连接配置数据 // 连接配置数据
edgeConfigs[i] = edge.EdgeID if edge.Type == EdgeInfoCloud {
} edgeConfigs = append(edgeConfigs, edge.EdgeID)
}
// 提交异步任务关闭通道
_, err = g.Asynq.Enqueue(
e.NewRemoveChannel(batch),
asynq.ProcessAt(expire),
)
if err != nil {
return nil, core.NewServErr("提交关闭通道任务失败", err)
} }
// 保存数据 // 保存数据
err = q.Q.Transaction(func(q *q.Query) error { err = q.Q.Transaction(func(q *q.Query) error {
var rs gen.ResultInfo // 更新使用记录
var err error
// 根据套餐类型和模式更新使用记录 switch resource.Type {
isShortType := resource.Type == m.ResourceTypeShort case m.ResourceTypeShort:
isLongType := resource.Type == m.ResourceTypeLong _, err = q.ResourceShort.
switch {
case isShortType:
rs, err = q.ResourceShort.
Where( Where(
q.ResourceShort.ID.Eq(*resource.ShortId), q.ResourceShort.ID.Eq(*resource.ShortId),
q.ResourceShort.Used.Eq(resource.Used), q.ResourceShort.Used.Eq(resource.Used),
@@ -185,8 +174,8 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
q.ResourceShort.LastAt.Value(now), q.ResourceShort.LastAt.Value(now),
) )
case isLongType: case m.ResourceTypeLong:
rs, err = q.ResourceLong. _, err = q.ResourceLong.
Where( Where(
q.ResourceLong.ID.Eq(*resource.LongId), q.ResourceLong.ID.Eq(*resource.LongId),
q.ResourceLong.Used.Eq(resource.Used), q.ResourceLong.Used.Eq(resource.Used),
@@ -204,9 +193,6 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
if err != nil { if err != nil {
return core.NewServErr("更新套餐使用记录失败", err) return core.NewServErr("更新套餐使用记录失败", err)
} }
if rs.RowsAffected == 0 {
return core.NewServErr("套餐使用记录不存在")
}
// 保存通道 // 保存通道
err = q.Channel. err = q.Channel.
@@ -239,8 +225,6 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
} }
// 提交配置 // 提交配置
secret := strings.Split(u.Z(proxy.Secret), ":")
gateway := g.NewGateway(proxy.IP.String(), secret[0], secret[1])
if env.RunMode == env.RunModeProd { if env.RunMode == env.RunModeProd {
// 连接节点到网关 // 连接节点到网关
@@ -255,10 +239,11 @@ func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceId int
// 启用网关代理通道 // 启用网关代理通道
err = gateway.GatewayPortConfigs(chanConfigs) err = gateway.GatewayPortConfigs(chanConfigs)
if err != nil { if err != nil {
slog.Warn("提交代理端口配置失败", "error", err.Error())
return nil, core.NewServErr(fmt.Sprintf("配置代理 %s 端口失败", proxy.IP.String()), err) return nil, core.NewServErr(fmt.Sprintf("配置代理 %s 端口失败", proxy.IP.String()), err)
} }
} else { } else {
slog.Debug("提交代理端口配置", "proxy", proxy.IP.String()) slog.Debug("提交代理端口配置", "proxy", proxy.IP.String(), "count", len(chanConfigs), "remote", len(edgeConfigs))
for _, item := range chanConfigs { for _, item := range chanConfigs {
str, _ := json.Marshal(item) str, _ := json.Marshal(item)
fmt.Println(string(str)) fmt.Println(string(str))
@@ -306,12 +291,6 @@ func (s *channelBaiyinProvider) RemoveChannels(batch string) error {
// 提交配置 // 提交配置
if env.RunMode == env.RunModeProd { if env.RunMode == env.RunModeProd {
// 断开节点连接
g.Cloud.CloudDisconnect(&g.CloudDisconnectReq{
Uuid: proxy.Mac,
Edge: &edgeConfigs,
})
// 清空通道配置 // 清空通道配置
secret := strings.Split(u.Z(proxy.Secret), ":") secret := strings.Split(u.Z(proxy.Secret), ":")
gateway := g.NewGateway(proxy.IP.String(), secret[0], secret[1]) gateway := g.NewGateway(proxy.IP.String(), secret[0], secret[1])
@@ -319,8 +298,18 @@ func (s *channelBaiyinProvider) RemoveChannels(batch string) error {
if err != nil { if err != nil {
return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err) return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
} }
// 断开节点连接
_, err = g.Cloud.CloudDisconnect(&g.CloudDisconnectReq{
Uuid: proxy.Mac,
Edge: &edgeConfigs,
})
if err != nil {
slog.Warn("断开云平台连接失败", "error", err.Error())
return core.NewServErr("断开云平台连接失败", err)
}
} else { } else {
slog.Debug("清除代理端口配置", "proxy", proxy.IP)
for _, item := range configs { for _, item := range configs {
str, _ := json.Marshal(item) str, _ := json.Marshal(item)
fmt.Println(string(str)) fmt.Println(string(str))
@@ -333,6 +322,91 @@ func (s *channelBaiyinProvider) RemoveChannels(batch string) error {
return err return err
} }
slog.Debug("清除代理端口配置", "duration", time.Since(start).String()) slog.Debug("清除代理端口配置", "proxy", proxy.IP.String(), "batch", batch, "duration", time.Since(start).String())
return nil return nil
} }
func (s *channelBaiyinProvider) ClearExpiredChannels() error {
now := time.Now().Add(time.Hour)
var batches []struct{ BatchNo string }
err := q.Channel.Select(q.Channel.BatchNo).Where(q.Channel.ExpiredAt.Lt(now)).Group(q.Channel.BatchNo).Scan(&batches)
if err != nil {
return core.NewServErr("查询过期通道失败", err)
}
for _, batch := range batches {
err := s.RemoveChannels(batch.BatchNo)
if err != nil {
slog.Error("清理过期通道失败", "batch", batch.BatchNo, "error", err)
}
}
return nil
}
func getAvailableEdges(gateway g.GatewayClient, filter *EdgeFilter, count int) ([]EdgeInfo, error) {
edges := make([]EdgeInfo, 0, count)
// 先查本地
localEdgesResp, err := gateway.GatewayEdge(&g.GatewayEdgeReq{
Province: filter.Prov,
City: filter.City,
Isp: u.X(filter.Isp.String()),
Limit: &count,
Assigned: u.P(false),
})
if err != nil {
return nil, core.NewBizErr("获取可用节点失败[1]", err)
}
for id, _ := range localEdgesResp {
edges = append(edges, EdgeInfo{
Type: EdgeInfoLocal,
EdgeID: id,
})
}
if len(edges) >= count {
return edges, nil
}
// 再查云端无重复
remaining := count - len(edges)
cloudEdgesResp, err := g.Cloud.CloudEdges(&g.CloudEdgesReq{
Province: filter.Prov,
City: filter.City,
Isp: u.X(filter.Isp.String()),
Limit: &remaining,
NoRepeat: u.P(true),
ActiveTime: u.P(3600),
IpUnchangedTime: u.P(3600),
})
if err != nil {
return nil, core.NewBizErr("获取可用节点失败[2]", err)
}
for _, edge := range cloudEdgesResp.Edges {
edges = append(edges, EdgeInfo{
Type: EdgeInfoCloud,
EdgeID: edge.EdgeID,
})
}
if len(edges) >= count {
return edges, nil
}
// 不能和已有的重复,如果有重复则再次查询云端补足,二次提取还有重复则放弃
return nil, core.NewBizErr("地区可用节点数量不足")
}
type EdgeInfo struct {
Type EdgeInfoType
EdgeID string
}
type EdgeInfoType string
const (
EdgeInfoLocal EdgeInfoType = "local"
EdgeInfoCloud EdgeInfoType = "cloud"
)

View File

@@ -68,14 +68,24 @@ func (s *couponService) Update(data UpdateCouponData) error {
do = append(do, q.Coupon.Status.Value(int(*data.Status))) do = append(do, q.Coupon.Status.Value(int(*data.Status)))
} }
if data.ExpireType != nil { if data.ExpireType != nil {
switch *data.ExpireType {
case m.CouponExpireTypeNever:
do = append(do, q.Coupon.ExpireAt.Null(), q.Coupon.ExpireIn.Null())
case m.CouponExpireTypeFixed:
if data.ExpireAt == nil {
return core.NewBizErr("expire_at 不能为空")
}
do = append(do, q.Coupon.ExpireAt.Value(*data.ExpireAt), q.Coupon.ExpireIn.Null())
case m.CouponExpireTypeRelative:
if data.ExpireIn == nil {
return core.NewBizErr("expire_in 不能为空")
}
do = append(do, q.Coupon.ExpireAt.Null(), q.Coupon.ExpireIn.Value(*data.ExpireIn))
}
do = append(do, q.Coupon.ExpireType.Value(int(*data.ExpireType))) do = append(do, q.Coupon.ExpireType.Value(int(*data.ExpireType)))
} }
if data.ExpireAt != nil {
do = append(do, q.Coupon.ExpireAt.Value(*data.ExpireAt))
}
if data.ExpireIn != nil {
do = append(do, q.Coupon.ExpireIn.Value(*data.ExpireIn))
}
_, err := q.Coupon.Where(q.Coupon.ID.Eq(data.ID)).UpdateSimple(do...) _, err := q.Coupon.Where(q.Coupon.ID.Eq(data.ID)).UpdateSimple(do...)
return err return err
@@ -98,6 +108,13 @@ func (s *couponService) Delete(id int32) error {
return err return err
} }
func (s *couponService) Assign(couponID int32, userID int32) error {
return CouponUser.Create(CreateCouponUserData{
CouponID: couponID,
UserID: userID,
})
}
// GetUserCoupon 获取用户的指定优惠券 // GetUserCoupon 获取用户的指定优惠券
func (s *couponService) GetUserCoupon(uid int32, cuid int32, amount decimal.Decimal) (*m.CouponUser, error) { func (s *couponService) GetUserCoupon(uid int32, cuid int32, amount decimal.Decimal) (*m.CouponUser, error) {
// 获取优惠券 // 获取优惠券
@@ -105,7 +122,7 @@ func (s *couponService) GetUserCoupon(uid int32, cuid int32, amount decimal.Deci
q.CouponUser.ID.Eq(cuid), q.CouponUser.ID.Eq(cuid),
q.CouponUser.UserID.Eq(uid), q.CouponUser.UserID.Eq(uid),
q.CouponUser.Status.Eq(int(m.CouponUserStatusUnused)), q.CouponUser.Status.Eq(int(m.CouponUserStatusUnused)),
q.CouponUser.ExpireAt.Gt(time.Now()), q.CouponUser.Where(q.CouponUser.ExpireAt.IsNull()).Or(q.CouponUser.ExpireAt.Gt(time.Now())),
).Take() ).Take()
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("优惠券不存在或已失效") return nil, core.NewBizErr("优惠券不存在或已失效")

255
web/services/coupon_user.go Normal file
View File

@@ -0,0 +1,255 @@
package services
import (
"errors"
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"time"
"gorm.io/gen/field"
"gorm.io/gorm"
)
var CouponUser = &couponUserService{}
type couponUserService struct{}
func (s *couponUserService) Create(data CreateCouponUserData) error {
now := time.Now()
status := u.Else(data.Status, m.CouponUserStatusUnused)
if err := validateCouponUserStatus(status); err != nil {
return err
}
return q.Q.Transaction(func(tx *q.Query) error {
coupon, err := tx.Coupon.Where(tx.Coupon.ID.Eq(data.CouponID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("优惠券不存在")
}
if err != nil {
return core.NewServErr("获取优惠券数据失败", err)
}
if coupon.Status != m.CouponStatusEnabled {
return core.NewBizErr("优惠券不可用")
}
if coupon.Count <= 0 {
return core.NewBizErr("优惠券已发放完")
}
_, err = tx.User.Where(tx.User.ID.Eq(data.UserID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("用户不存在")
}
if err != nil {
return core.NewServErr("获取用户数据失败", err)
}
expireAt := data.ExpireAt
if expireAt == nil {
expireAt = couponUserExpireAt(coupon, now)
}
usedAt := data.UsedAt
if status == m.CouponUserStatusUsed && usedAt == nil {
usedAt = &now
}
if status == m.CouponUserStatusUnused {
usedAt = nil
}
err = tx.CouponUser.Create(&m.CouponUser{
UserID: data.UserID,
CouponID: data.CouponID,
Status: status,
ExpireAt: expireAt,
UsedAt: usedAt,
})
if err != nil {
return core.NewServErr("发放优惠券失败", err)
}
return adjustCouponCount(tx, coupon.ID, -1)
})
}
type CreateCouponUserData struct {
CouponID int32 `json:"coupon_id" validate:"required"`
UserID int32 `json:"user_id" validate:"required"`
Status *m.CouponUserStatus `json:"status"`
ExpireAt *time.Time `json:"expire_at"`
UsedAt *time.Time `json:"used_at"`
}
func (s *couponUserService) Update(data UpdateCouponUserData) error {
return q.Q.Transaction(func(tx *q.Query) error {
current, err := tx.CouponUser.Where(tx.CouponUser.ID.Eq(data.ID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewServErr("获取已发放优惠券失败", err)
}
do := make([]field.AssignExpr, 0)
if data.ExpireAtClear != nil && *data.ExpireAtClear {
do = append(do, tx.CouponUser.ExpireAt.Null())
} else if data.ExpireAt != nil {
do = append(do, tx.CouponUser.ExpireAt.Value(*data.ExpireAt))
}
if data.UsedAtClear != nil && *data.UsedAtClear {
do = append(do, tx.CouponUser.UsedAt.Null())
} else if data.UsedAt != nil {
do = append(do, tx.CouponUser.UsedAt.Value(*data.UsedAt))
}
if data.Status != nil {
if err := validateCouponUserStatus(*data.Status); err != nil {
return err
}
if current.Status != *data.Status {
if current.Status == m.CouponUserStatusUsed {
return core.NewBizErr("已使用的优惠券不能修改状态")
}
if current.Status == m.CouponUserStatusDisabled && *data.Status == m.CouponUserStatusUsed {
return core.NewBizErr("已禁用的优惠券不能标记为已使用")
}
switch *data.Status {
case m.CouponUserStatusUnused:
if current.Status == m.CouponUserStatusDisabled {
if err := adjustCouponCount(tx, current.CouponID, -1); err != nil {
return err
}
}
if data.UsedAt == nil && (data.UsedAtClear == nil || !*data.UsedAtClear) {
do = append(do, tx.CouponUser.UsedAt.Null())
}
case m.CouponUserStatusUsed:
if data.UsedAt == nil && (data.UsedAtClear == nil || !*data.UsedAtClear) {
do = append(do, tx.CouponUser.UsedAt.Value(time.Now()))
}
case m.CouponUserStatusDisabled:
if current.Status == m.CouponUserStatusUnused {
if err := adjustCouponCount(tx, current.CouponID, 1); err != nil {
return err
}
}
}
do = append(do, tx.CouponUser.Status.Value(int(*data.Status)))
}
}
if len(do) == 0 {
return nil
}
result, err := tx.CouponUser.
Where(
tx.CouponUser.ID.Eq(data.ID),
tx.CouponUser.Status.Eq(int(current.Status)),
).
UpdateSimple(do...)
if err != nil {
return core.NewServErr("更新已发放优惠券失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("已发放优惠券状态已变化,请重试")
}
return nil
})
}
type UpdateCouponUserData struct {
ID int32 `json:"id" validate:"required"`
Status *m.CouponUserStatus `json:"status"`
ExpireAt *time.Time `json:"expire_at"`
ExpireAtClear *bool `json:"expire_at_clear"`
UsedAt *time.Time `json:"used_at"`
UsedAtClear *bool `json:"used_at_clear"`
}
func (s *couponUserService) Delete(id int32) error {
status := m.CouponUserStatusDisabled
return s.Update(UpdateCouponUserData{
ID: id,
Status: &status,
})
}
func (s *couponUserService) DeleteOfUser(id int32, userID int32) error {
assigned, err := q.CouponUser.Where(
q.CouponUser.ID.Eq(id),
q.CouponUser.UserID.Eq(userID),
).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewServErr("获取已发放优惠券失败", err)
}
if assigned.Status != m.CouponUserStatusUnused {
return core.NewBizErr("只能撤销未使用的优惠券")
}
return s.Delete(id)
}
func couponUserExpireAt(coupon *m.Coupon, now time.Time) *time.Time {
if coupon == nil {
return nil
}
switch coupon.ExpireType {
case m.CouponExpireTypeFixed:
return coupon.ExpireAt
case m.CouponExpireTypeRelative:
if coupon.ExpireIn == nil {
return nil
}
expireAt := now.Add(time.Duration(*coupon.ExpireIn) * 24 * time.Hour)
return &expireAt
default:
return nil
}
}
func validateCouponUserStatus(status m.CouponUserStatus) error {
switch status {
case m.CouponUserStatusUnused, m.CouponUserStatusUsed, m.CouponUserStatusDisabled:
return nil
default:
return core.NewBizErr("优惠券发放状态无效")
}
}
func adjustCouponCount(tx *q.Query, couponID int32, delta int32) error {
coupon, err := tx.Coupon.Where(tx.Coupon.ID.Eq(couponID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("优惠券不存在")
}
if err != nil {
return core.NewServErr("获取优惠券数据失败", err)
}
next := coupon.Count + delta
if next < 0 {
return core.NewBizErr("优惠券已发放完")
}
result, err := tx.Coupon.
Where(tx.Coupon.ID.Eq(couponID), tx.Coupon.Count_.Eq(coupon.Count)).
UpdateSimple(tx.Coupon.Count_.Value(next))
if err != nil {
return core.NewServErr("更新优惠券数量失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("优惠券库存已变化,请重试")
}
return nil
}

View File

@@ -63,9 +63,10 @@ func (s *resourceService) Create(q *q.Query, uid int32, now time.Time, data *Cre
var resource = m.Resource{ var resource = m.Resource{
UserID: uid, UserID: uid,
ResourceNo: u.P(ID.GenReadable("res")), ResourceNo: u.P(ID.GenReadable("res")),
Active: true,
Type: data.Type, Type: data.Type,
Code: data.Type.Code(), Code: data.Type.Code(),
Active: true,
CheckIP: true,
} }
switch data.Type { switch data.Type {

View File

@@ -49,3 +49,11 @@ func HandleRemoveChannel(_ context.Context, task *asynq.Task) (err error) {
} }
return nil return nil
} }
func HandleClearExpiredChannels(_ context.Context, _ *asynq.Task) (err error) {
err = s.Channel.ClearExpiredChannels()
if err != nil {
return fmt.Errorf("清理过期通道失败: %w", err)
}
return nil
}

View File

@@ -42,6 +42,10 @@ func RunApp(pCtx context.Context) error {
return RunTask(ctx) return RunTask(ctx)
}) })
g.Go(func() error {
return RunCron(ctx)
})
return g.Wait() return g.Wait()
} }
@@ -90,6 +94,7 @@ func RunTask(ctx context.Context) error {
var mux = asynq.NewServeMux() var mux = asynq.NewServeMux()
mux.HandleFunc(events.RemoveChannel, tasks.HandleRemoveChannel) mux.HandleFunc(events.RemoveChannel, tasks.HandleRemoveChannel)
mux.HandleFunc(events.CloseTrade, tasks.HandleCompleteTrade) mux.HandleFunc(events.CloseTrade, tasks.HandleCompleteTrade)
mux.HandleFunc(events.ClearExpiredChannels, tasks.HandleClearExpiredChannels)
// 停止服务 // 停止服务
go func() { go func() {
@@ -105,3 +110,26 @@ func RunTask(ctx context.Context) error {
return nil return nil
} }
func RunCron(ctx context.Context) error {
server := asynq.NewSchedulerFromRedisClient(deps.Redis, &asynq.SchedulerOpts{
Location: time.Local,
})
// 每小时清理一次一小时之前的过期通道
server.Register("0 * * * *", asynq.NewTask(events.ClearExpiredChannels, nil))
// 停止服务
go func() {
<-ctx.Done()
server.Shutdown()
}()
// 启动服务
err := server.Run()
if err != nil {
return fmt.Errorf("定时任务服务运行失败: %w", err)
}
return nil
}