12 Commits

36 changed files with 9766 additions and 7008 deletions

View File

@@ -59,3 +59,4 @@ SFTPAY_ROUTE_ID=
SFTPAY_APP_PRIVATE_KEY= SFTPAY_APP_PRIVATE_KEY=
SFTPAY_PUBLIC_KEY= SFTPAY_PUBLIC_KEY=
SFTPAY_RETURN_URL= SFTPAY_RETURN_URL=
SFTPAY_NOTIFY_URL=

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "main",
"type": "go",
"request": "launch",
"mode": "debug",
"cwd": "${workspaceFolder}",
"program": "cmd/main",
}
]
}

View File

@@ -1,5 +1,30 @@
## TODO ## TODO
清除代理的并发问题,redis 归还后,还有可能 gost 没有完成解除占用,导致端口冲突,考虑先等几秒再释放 redis
现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划
后台首页接口:
- 待认领客户数量
- 活跃代理数
- 今日交易额
- 今日提取数量
- 今日新增客户
- 每日活跃代理
- 每日交易额
- 每日新增客户
- 每日提取量
- 全局可提取统计(包时套餐计算最大可提取,每日提取上限 * 剩余可提取天数)
- 长效套餐
- 短效套餐
- 最近活跃用户(七日内 ip 使用量)
---
关闭支付弹窗
后台限制 ip
- edge.area_id 可为空,代表节点无固定地区 - edge.area_id 可为空,代表节点无固定地区
- 后台展示 mac, ip:port,实际地区 - 后台展示 mac, ip:port,实际地区
@@ -29,20 +54,24 @@
1. 创建 model 文件 1. 创建 model 文件
2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中 2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: 3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为有必要的字段添加索引 - 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为数据表及其字段添加注释 - 为有必要的字段添加索引
- 在文件末尾创建数据表流程全部结束后,为字段添加外键 - 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件 4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
### 更新数据表流程 ### 更新数据表流程
1. 更新 model 文件 1. 更新 model 文件
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: 2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为有必要的字段添加索引 - 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为数据表及其字段添加注释 - 为有必要的字段添加索引
- 在文件末尾创建数据表流程全部结束后,为字段添加外键 - 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件 3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
### 新增接口或修改接口权限 ### 新增接口或修改接口权限

View File

@@ -40,14 +40,6 @@ services:
depends_on: depends_on:
- redis - redis
gost:
image: gogost/gost
command: >
-api test:test@:9700
ports:
- "9700:9700"
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -21,7 +21,6 @@ require (
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/smartwalle/alipay/v3 v3.2.28 github.com/smartwalle/alipay/v3 v3.2.28
github.com/valyala/fasthttp v1.68.0
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/wechatpay-apiv3/wechatpay-go v0.2.21
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
@@ -85,6 +84,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib v1.38.0 // indirect go.opentelemetry.io/contrib v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect

View File

@@ -184,6 +184,7 @@ values
('许昌', 2, (select id from area where name = '河南')), ('许昌', 2, (select id from area where name = '河南')),
('郑州', 2, (select id from area where name = '河南')), ('郑州', 2, (select id from area where name = '河南')),
('驻马店', 2, (select id from area where name = '河南')), ('驻马店', 2, (select id from area where name = '河南')),
('平顶山', 2, (select id from area where name = '河南')),
('鹤壁', 2, (select id from area where name = '河南')), ('鹤壁', 2, (select id from area where name = '河南')),
('丽水', 2, (select id from area where name = '浙江')), ('丽水', 2, (select id from area where name = '浙江')),
('台州', 2, (select id from area where name = '浙江')), ('台州', 2, (select id from area where name = '浙江')),

File diff suppressed because it is too large Load Diff

View File

@@ -251,6 +251,10 @@ 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 = 'resource:long' and deleted_at is null), 'resource:long:read', '读取用户长效动态套餐列表', 1); ((select id from permission where name = 'resource:long' and deleted_at is null), 'resource:long:read', '读取用户长效动态套餐列表', 1);
-- resource:read 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'resource:read' and deleted_at is null), 'resource:read:of_user', '读取指定用户的用户套餐列表', 1);
-- user:read 子权限 -- user:read 子权限
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:read' and deleted_at is null), 'user:read:one', '读取单个用户', 1), ((select id from permission where name = 'user:read' and deleted_at is null), 'user:read:one', '读取单个用户', 1),

View File

@@ -175,6 +175,7 @@ drop table if exists article cascade;
create table article ( create table article (
id int generated by default as identity primary key, id int generated by default as identity primary key,
group_id int not null, group_id int not null,
code text,
title text not null, title text not null,
content text, content text,
sort int not null default 0, sort int not null default 0,
@@ -183,6 +184,7 @@ create table article (
updated_at timestamptz default current_timestamp, updated_at timestamptz default current_timestamp,
deleted_at timestamptz deleted_at timestamptz
); );
create unique index udx_article_code on article (code) where code is not null and deleted_at is null;
create index idx_article_group_id on article (group_id) where deleted_at is null; create index idx_article_group_id on article (group_id) where deleted_at is null;
create index idx_article_status on article (status) where deleted_at is null; create index idx_article_status on article (status) where deleted_at is null;
create index idx_article_sort on article (sort) where deleted_at is null; create index idx_article_sort on article (sort) where deleted_at is null;
@@ -191,6 +193,7 @@ create index idx_article_sort on article (sort) where deleted_at is null;
comment on table article is '文章表'; comment on table article is '文章表';
comment on column article.id is '文章ID'; comment on column article.id is '文章ID';
comment on column article.group_id is '分组ID'; comment on column article.group_id is '分组ID';
comment on column article.code is '文章编码,用于固定引用链接';
comment on column article.title is '文章标题'; comment on column article.title is '文章标题';
comment on column article.content is '文章内容'; comment on column article.content is '文章内容';
comment on column article.sort is '文章排序'; comment on column article.sort is '文章排序';
@@ -614,8 +617,8 @@ create table proxy (
updated_at timestamptz default current_timestamp, updated_at timestamptz default current_timestamp,
deleted_at timestamptz deleted_at timestamptz
); );
create unique index udx_proxy_mac on proxy (mac) where deleted_at is null; create index idx_proxy_mac on proxy (mac) where deleted_at is null;
create unique index udx_proxy_ip on proxy (ip) where deleted_at is null; create index idx_proxy_ip on proxy (ip) where deleted_at is null;
create index idx_proxy_created_at on proxy (created_at) where deleted_at is null; create index idx_proxy_created_at on proxy (created_at) where deleted_at is null;
-- proxy表字段注释 -- proxy表字段注释

View File

@@ -29,6 +29,7 @@ const (
ScopeResource = string("resource") // 用户套餐 ScopeResource = string("resource") // 用户套餐
ScopeResourceRead = string("resource:read") // 读取用户套餐列表 ScopeResourceRead = string("resource:read") // 读取用户套餐列表
ScopeResourceWrite = string("resource:write") // 写入用户套餐 ScopeResourceWrite = string("resource:write") // 写入用户套餐
ScopeResourceReadOfUser = string("resource:read:of_user") // 读取指定用户的用户套餐列表
ScopeResourceShort = string("resource:short") // 短效动态套餐 ScopeResourceShort = string("resource:short") // 短效动态套餐
ScopeResourceShortRead = string("resource:short:read") // 读取用户短效动态套餐列表 ScopeResourceShortRead = string("resource:short:read") // 读取用户短效动态套餐列表

View File

@@ -1,9 +1,7 @@
package events package events
import "github.com/hibiken/asynq"
const RefreshEdge = "edge:refresh" const RefreshEdge = "edge:refresh"
func NewRefreshEdge() *asynq.Task { // func NewRefreshEdge() *asynq.Task {
return asynq.NewTask(RefreshEdge, nil) // return asynq.NewTask(RefreshEdge, nil)
} // }

View File

@@ -42,7 +42,7 @@ type gostClient struct {
var GostInitializer = func(host string, port int, pathPrefix, username, password string) GostClient { var GostInitializer = func(host string, port int, pathPrefix, username, password string) GostClient {
baseURL := strings.TrimSpace(host) baseURL := strings.TrimSpace(host)
if !strings.Contains(baseURL, "://") { if !strings.Contains(baseURL, "://") {
baseURL = fmt.Sprintf("http://%s:%d", baseURL, port) baseURL = fmt.Sprintf("https://%s", baseURL)
} }
return &gostClient{ return &gostClient{
@@ -89,6 +89,8 @@ type GostServiceConfig struct {
Handler GostHandlerConfig `json:"handler"` Handler GostHandlerConfig `json:"handler"`
Listener GostListenerConfig `json:"listener"` Listener GostListenerConfig `json:"listener"`
Recorders []GostRecorderConfig `json:"recorders,omitempty"` Recorders []GostRecorderConfig `json:"recorders,omitempty"`
Limiter string `json:"limiter,omitempty"`
Bypasses []string `json:"bypasses,omitempty"`
} }
type GostHandlerConfig struct { type GostHandlerConfig struct {

View File

@@ -443,3 +443,11 @@ const (
SftRefundIng SftTradeState = "REFUND_ING" SftRefundIng SftTradeState = "REFUND_ING"
SftTradeClosed SftTradeState = "TRADE_CLOSED" SftTradeClosed SftTradeState = "TRADE_CLOSED"
) )
type SftNotifyType string
var (
SftNotifyTypeTrade = SftNotifyType("sft.trade.notify")
SftNotifyTypeRefund = SftNotifyType("sft.refund.notify")
SftNotifyTypeDivision = SftNotifyType("sft.division.notify")
)

View File

@@ -31,12 +31,12 @@ func GetArticle(c *fiber.Ctx) error {
return err return err
} }
var req core.IdReq var req s.GetArticleReq
if err := g.Validator.ParseBody(c, &req); err != nil { if err := g.Validator.ParseBody(c, &req); err != nil {
return err return err
} }
article, err := s.Article.GetPublic(req.Id) article, err := s.Article.GetPublic(req)
if err != nil { if err != nil {
return err return err
} }

56
web/handlers/home.go Normal file
View File

@@ -0,0 +1,56 @@
package handlers
import (
"platform/web/auth"
g "platform/web/globals"
s "platform/web/services"
"github.com/gofiber/fiber/v2"
)
// HomeOverview 后台首页概览统计
func HomeOverview(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
resp, err := s.Home.Overview()
if err != nil {
return err
}
return c.JSON(resp)
}
// HomeTrend 后台首页每日趋势统计
func HomeTrend(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
var req s.HomeTrendReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
resp, err := s.Home.Trend(req)
if err != nil {
return err
}
return c.JSON(resp)
}
// HomeExtractable 后台首页全局可提取统计
func HomeExtractable(c *fiber.Ctx) error {
if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil {
return err
}
resp, err := s.Home.Extractable()
if err != nil {
return err
}
return c.JSON(resp)
}

View File

@@ -15,6 +15,176 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// PageResource 分页查询当前用户长短效套餐
func PageResource(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
req := new(PageResourceReq)
if err := c.BodyParser(req); err != nil {
return err
}
short := q.ResourceShort.As(q.Resource.Short.Name())
long := q.ResourceLong.As(q.Resource.Long.Name())
do := q.Resource.Where(q.Resource.UserID.Eq(authCtx.User.ID))
if req.ResourceNo != nil && *req.ResourceNo != "" {
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
}
if req.Active != nil {
do = do.Where(q.Resource.Active.Is(*req.Active))
}
if req.ResourceType != nil {
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
}
if req.Type != nil {
if req.ResourceType != nil {
switch m.ResourceType(*req.ResourceType) {
case m.ResourceTypeShort:
do = do.Where(short.Type.Eq(*req.Type))
case m.ResourceTypeLong:
do = do.Where(long.Type.Eq(*req.Type))
}
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(*req.Type),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(*req.Type),
))
}
}
if req.CreateAfter != nil {
do = do.Where(q.Resource.CreatedAt.Gte(req.CreateAfter.UTC()))
}
if req.CreateBefore != nil {
do = do.Where(q.Resource.CreatedAt.Lte(req.CreateBefore.UTC()))
}
if req.ExpireAfter != nil {
if req.ResourceType != nil {
switch m.ResourceType(*req.ResourceType) {
case m.ResourceTypeShort:
do = do.Where(short.ExpireAt.Gte(req.ExpireAfter.UTC()))
case m.ResourceTypeLong:
do = do.Where(long.ExpireAt.Gte(req.ExpireAfter.UTC()))
}
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.ExpireAt.Gte(req.ExpireAfter.UTC()),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.ExpireAt.Gte(req.ExpireAfter.UTC()),
))
}
}
if req.ExpireBefore != nil {
if req.ResourceType != nil {
switch m.ResourceType(*req.ResourceType) {
case m.ResourceTypeShort:
do = do.Where(short.ExpireAt.Lte(req.ExpireBefore.UTC()))
case m.ResourceTypeLong:
do = do.Where(long.ExpireAt.Lte(req.ExpireBefore.UTC()))
}
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.ExpireAt.Lte(req.ExpireBefore.UTC()),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.ExpireAt.Lte(req.ExpireBefore.UTC()),
))
}
}
if req.Status != nil {
now := time.Now().UTC()
switch *req.Status {
case 1:
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Gte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.GtCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Gte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.GtCol(long.Used),
))
case 2:
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.LteCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.LteCol(long.Used),
))
}
}
resource, err := q.Resource.Where(do).
Joins(q.Resource.Short, q.Resource.Long).
Order(q.Resource.CreatedAt.Desc()).
Offset(req.GetOffset()).
Limit(req.GetLimit()).
Find()
if err != nil {
return err
}
var total int64
if len(resource) < req.GetLimit() {
total = int64(len(resource) + req.GetOffset())
} else {
total, err = q.Resource.
Joins(q.Resource.Short, q.Resource.Long).
Where(do).
Count()
if err != nil {
return err
}
}
return c.JSON(core.PageResp{
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
List: resource,
})
}
type PageResourceReq struct {
core.PageReq
ResourceNo *string `json:"resource_no"`
Active *bool `json:"active"`
ResourceType *int `json:"resource_type"` // 1 - 短效动态,2 - 长效动态
Type *int `json:"type"` // 1 - 包时,2 - 包量
CreateAfter *time.Time `json:"create_after"`
CreateBefore *time.Time `json:"create_before"`
ExpireAfter *time.Time `json:"expire_after"`
ExpireBefore *time.Time `json:"expire_before"`
Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期
}
// PageResourceShort 分页查询当前用户短效套餐 // PageResourceShort 分页查询当前用户短效套餐
func PageResourceShort(c *fiber.Ctx) error { func PageResourceShort(c *fiber.Ctx) error {
// 检查权限 // 检查权限
@@ -209,6 +379,148 @@ type PageResourceLongReq struct {
Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期 Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期
} }
// PageResourceByAdmin 分页查询全部长短效套餐
func PageResourceByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceRead)
if err != nil {
return err
}
var req PageResourceByAdminReq
if err = g.Validator.ParseBody(c, &req); err != nil {
return err
}
short := q.ResourceShort.As("Short")
long := q.ResourceLong.As("Long")
do := q.Resource.Where()
if req.UserPhone != nil {
do = do.Where(q.User.As("User").Phone.Eq(*req.UserPhone))
}
if req.ResourceNo != nil {
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
}
if req.Active != nil {
do = do.Where(q.Resource.Active.Is(*req.Active))
}
if req.ResourceType != nil {
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
}
if req.Mode != nil {
if req.ResourceType != nil {
switch m.ResourceType(*req.ResourceType) {
case m.ResourceTypeShort:
do = do.Where(short.Type.Eq(*req.Mode))
case m.ResourceTypeLong:
do = do.Where(long.Type.Eq(*req.Mode))
}
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(*req.Mode),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(*req.Mode),
))
}
}
if req.CreatedAtStart != nil {
do = do.Where(q.Resource.CreatedAt.Gte(req.CreatedAtStart.UTC()))
}
if req.CreatedAtEnd != nil {
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
}
if req.Expired != nil {
now := time.Now().UTC()
if *req.Expired {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.LteCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.LteCol(long.Used),
))
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Gt(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.GtCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Gt(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.GtCol(long.Used),
))
}
}
list, total, err := q.Resource.
Joins(q.Resource.User, q.Resource.Short, q.Resource.Long, q.Resource.Short.Sku, q.Resource.Long.Sku).
Select(
q.Resource.ALL,
q.User.As("User").Phone.As("User__phone"),
q.User.As("User").Name.As("User__name"),
short.Type.As("Short__type"),
short.Live.As("Short__live"),
short.Quota.As("Short__quota"),
short.Used.As("Short__used"),
short.Daily.As("Short__daily"),
short.LastAt.As("Short__last_at"),
short.ExpireAt.As("Short__expire_at"),
q.ProductSku.As("Short__Sku").Name.As("Short__Sku__name"),
long.Type.As("Long__type"),
long.Live.As("Long__live"),
long.Quota.As("Long__quota"),
long.Used.As("Long__used"),
long.Daily.As("Long__daily"),
long.LastAt.As("Long__last_at"),
long.ExpireAt.As("Long__expire_at"),
q.ProductSku.As("Long__Sku").Name.As("Long__Sku__name"),
).
Where(do).
Order(q.Resource.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return err
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageResourceByAdminReq struct {
core.PageReq
UserPhone *string `json:"user_phone" form:"user_phone"`
ResourceNo *string `json:"resource_no" form:"resource_no"`
Active *bool `json:"active" form:"active"`
ResourceType *int `json:"resource_type" form:"resource_type"`
Mode *int `json:"mode" form:"mode"`
CreatedAtStart *time.Time `json:"created_at_start" form:"created_at_start"`
CreatedAtEnd *time.Time `json:"created_at_end" form:"created_at_end"`
Expired *bool `json:"expired" form:"expired"`
}
// PageResourceShortByAdmin 分页查询全部短效套餐 // PageResourceShortByAdmin 分页查询全部短效套餐
func PageResourceShortByAdmin(c *fiber.Ctx) error { func PageResourceShortByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead) _, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead)
@@ -393,6 +705,145 @@ type PageResourceLongByAdminReq struct {
Expired *bool `json:"expired" form:"expired"` Expired *bool `json:"expired" form:"expired"`
} }
// PageResourceOfUserByAdmin 分页查询指定用户的长短效套餐
func PageResourceOfUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceReadOfUser)
if err != nil {
return err
}
var req PageResourceOfUserByAdminReq
if err = g.Validator.ParseBody(c, &req); err != nil {
return err
}
short := q.ResourceShort.As("Short")
long := q.ResourceLong.As("Long")
do := q.Resource.Where(q.Resource.UserID.Eq(req.UserID))
if req.ResourceNo != nil {
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
}
if req.Active != nil {
do = do.Where(q.Resource.Active.Is(*req.Active))
}
if req.ResourceType != nil {
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
}
if req.Mode != nil {
if req.ResourceType != nil {
switch m.ResourceType(*req.ResourceType) {
case m.ResourceTypeShort:
do = do.Where(short.Type.Eq(*req.Mode))
case m.ResourceTypeLong:
do = do.Where(long.Type.Eq(*req.Mode))
}
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(*req.Mode),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(*req.Mode),
))
}
}
if req.CreatedAtStart != nil {
do = do.Where(q.Resource.CreatedAt.Gte(req.CreatedAtStart.UTC()))
}
if req.CreatedAtEnd != nil {
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
}
if req.Expired != nil {
now := time.Now().UTC()
if *req.Expired {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.LteCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Lte(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.LteCol(long.Used),
))
} else {
do = do.Where(q.Resource.Where(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeTime)),
short.ExpireAt.Gt(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
short.Type.Eq(int(m.ResourceModeQuota)),
short.Quota.GtCol(short.Used),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeTime)),
long.ExpireAt.Gt(now),
).Or(
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
long.Type.Eq(int(m.ResourceModeQuota)),
long.Quota.GtCol(long.Used),
))
}
}
list, total, err := q.Resource.
Joins(q.Resource.User, q.Resource.Short, q.Resource.Long, q.Resource.Short.Sku, q.Resource.Long.Sku).
Select(
q.Resource.ALL,
q.User.As("User").Phone.As("User__phone"),
q.User.As("User").Name.As("User__name"),
short.Type.As("Short__type"),
short.Live.As("Short__live"),
short.Quota.As("Short__quota"),
short.Used.As("Short__used"),
short.Daily.As("Short__daily"),
short.LastAt.As("Short__last_at"),
short.ExpireAt.As("Short__expire_at"),
q.ProductSku.As("Short__Sku").Name.As("Short__Sku__name"),
long.Type.As("Long__type"),
long.Live.As("Long__live"),
long.Quota.As("Long__quota"),
long.Used.As("Long__used"),
long.Daily.As("Long__daily"),
long.LastAt.As("Long__last_at"),
long.ExpireAt.As("Long__expire_at"),
q.ProductSku.As("Long__Sku").Name.As("Long__Sku__name"),
).
Where(do).
Order(q.Resource.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return err
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageResourceOfUserByAdminReq struct {
core.PageReq
UserID int32 `json:"user_id" validate:"required"`
ResourceNo *string `json:"resource_no"`
Active *bool `json:"active"`
ResourceType *int `json:"resource_type"`
Mode *int `json:"mode"`
CreatedAtStart *time.Time `json:"created_at_start"`
CreatedAtEnd *time.Time `json:"created_at_end"`
Expired *bool `json:"expired"`
}
// PageResourceShortOfUserByAdmin 分页查询指定用户的短效套餐 // PageResourceShortOfUserByAdmin 分页查询指定用户的短效套餐
func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error { func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser) _, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser)
@@ -422,7 +873,7 @@ func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC())) do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
} }
list, total, err := q.Resource.Debug(). list, total, err := q.Resource.
Joins(q.Resource.User, q.Resource.Short, q.Resource.Short.Sku). Joins(q.Resource.User, q.Resource.Short, q.Resource.Short.Sku).
Select( Select(
q.Resource.ALL, q.Resource.ALL,

View File

@@ -1,10 +1,8 @@
package handlers package handlers
import ( import (
"bufio" "encoding/json"
"fmt"
"log/slog" "log/slog"
"platform/pkg/env"
"platform/web/auth" "platform/web/auth"
"platform/web/core" "platform/web/core"
g "platform/web/globals" g "platform/web/globals"
@@ -14,7 +12,7 @@ import (
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp" "github.com/shopspring/decimal"
) )
// PageTradeByAdmin 分页查询所有订单 // PageTradeByAdmin 分页查询所有订单
@@ -221,6 +219,36 @@ type TradeCreateReq struct {
// ============================================================ // ============================================================
// 更新订单备注
func TradeUpdateRemarkByAdmin(c *fiber.Ctx) error {
// 检查权限
_, err := auth.GetAuthCtx(c).PermitAdmin()
if err != nil {
return err
}
// 解析请求参数
var req TradeUpdateRemarkReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
// 更新订单备注
err = s.Trade.UpdateRemark(req.TradeNo, req.Remark)
if err != nil {
return err
}
return c.SendStatus(fiber.StatusNoContent)
}
type TradeUpdateRemarkReq struct {
TradeNo string `json:"trade_no" validate:"required"`
Remark string `json:"remark"`
}
// ============================================================
// 完成订单 // 完成订单
func TradeComplete(c *fiber.Ctx) error { func TradeComplete(c *fiber.Ctx) error {
// 检查权限 // 检查权限
@@ -276,6 +304,29 @@ func TradeCompleteByAdmin(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)
} }
// 订单补余额
func TradeConvertByAdmin(c *fiber.Ctx) error {
// 检查权限
authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeTradeWrite)
if err != nil {
return err
}
// 解析请求参数
var req s.TradeRef
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
// 订单补余额
err = s.Trade.ConvertTradeToBalance(authCtx.Admin, &req)
if err != nil {
return err
}
return c.SendStatus(fiber.StatusNoContent)
}
// ============================================================ // ============================================================
// 取消订单 // 取消订单
@@ -287,14 +338,14 @@ func TradeCancel(c *fiber.Ctx) error {
} }
// 解析请求参数 // 解析请求参数
req := new(TradeCancelReq) req := new(s.TradeRef)
if err := g.Validator.ParseBody(c, req); err != nil { if err := g.Validator.ParseBody(c, req); err != nil {
return err return err
} }
// 取消交易 // 取消交易
err = s.Trade.CancelTrade(&req.TradeRef) err = s.Trade.CancelTrade(req)
if err != nil { if err != nil && err != s.ErrTradeStatusIgnored {
slog.Error("取消交易失败", "trade_no", req.TradeNo, "error", err) slog.Error("取消交易失败", "trade_no", req.TradeNo, "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "取消交易失败"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "取消交易失败"})
} }
@@ -302,62 +353,177 @@ func TradeCancel(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)
} }
type TradeCancelReq struct { // 管理员取消订单
s.TradeRef func TradeCancelByAdmin(c *fiber.Ctx) error {
// 解析请求参数
req := new(s.TradeRef)
if err := g.Validator.ParseBody(c, req); err != nil {
return err
}
// 取消交易
err := s.Trade.CancelTrade(req)
if err != nil && err != s.ErrTradeStatusIgnored {
slog.Error("取消交易失败", "trade_no", req.TradeNo, "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "取消交易失败"})
}
return c.SendStatus(fiber.StatusNoContent)
}
// ============================================================
// 结束订单:完成或取消订单
func TradeFinish(c *fiber.Ctx) error {
// 检查权限
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
// 解析请求参数
var req s.TradeRef
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
// 尝试取消交易
err = s.Trade.CancelTrade(&req)
if err == s.ErrTradeStatusIgnored {
// 尝试完成交易
err = s.Trade.CompleteTrade(authCtx.User, &req)
if err != nil {
return err
}
return c.JSON(map[string]m.TradeStatus{
"status": m.TradeStatusSuccess,
})
} else if err != nil {
return err
}
return c.JSON(map[string]m.TradeStatus{
"status": m.TradeStatusCanceled,
})
} }
// ============================================================ // ============================================================
// 检查订单 // 检查订单
func TradeCheck(c *fiber.Ctx) error { func TradeCheck(c *fiber.Ctx) error {
// 检查权限:sse 接口暂时不检查权限 _, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeTradeRead)
if err != nil {
// 解析请求参数
req := new(TradeCheckReq)
if err := g.Validator.ParseQuery(c, req); err != nil {
return err return err
} }
c.Set(fiber.HeaderContentType, "text/event-stream") // 解析请求参数
c.Set(fiber.HeaderCacheControl, "no-cache") var req s.TradeRef
c.Set(fiber.HeaderConnection, "keep-alive") if err := g.Validator.ParseBody(c, &req); err != nil {
c.Set(fiber.HeaderTransferEncoding, "chunked") return err
c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(func(w *bufio.Writer) { }
expire := env.TradeExpire
interval := 5
for range expire / interval {
// 检查订单状态 // 检查订单状态
result, err := s.Trade.CheckTrade(&req.TradeRef) result, err := s.Trade.CheckTrade(&req)
if err != nil { if err != nil {
slog.Error("检查订单状态失败", "trade_no", req.TradeNo, "error", err) return err
return
} }
// 写入订单状态 return c.JSON(result)
_, err = fmt.Fprintf(w, "data: %d\n\n", result.Status)
if err != nil {
slog.Error("写入订单状态失败", "trade_no", req.TradeNo, "error", err)
return
}
err = w.Flush()
if err != nil {
return
}
// 当订单离开支付状态后结束查询
if result.Status != m.TradeStatusPending {
return
}
time.Sleep(time.Duration(interval) * time.Second)
}
}))
return nil
} }
type TradeCheckReq struct { // ============================================================
s.TradeRef
// 交易回调
func TradeCallbackSft(c *fiber.Ctx) error {
// 解析请求参数
var req TradeCallbackSftReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return core.NewServErr("解析请求失败", err)
}
var bizData TradeCallbackSftReqBizData
if err := json.Unmarshal([]byte(*req.BizData), &bizData); err != nil {
return core.NewServErr("解析业务数据失败", err)
}
// 检查通知
if bizData.MsgType != string(g.SftNotifyTypeTrade) {
return core.NewBizErr("消息类型处理异常")
}
if bizData.State != string(g.SftTradeSuccess) {
return core.NewBizErr("订单状态处理异常")
}
// 更新订单状态
if bizData.PayType == nil {
return core.NewBizErr("支付渠道解析错误")
}
var acquirer m.TradeAcquirer
switch *bizData.PayType {
case string(g.SftAlipay):
acquirer = m.TradeAcquirerAlipay
case string(g.SftWeChat):
acquirer = m.TradeAcquirerWechat
case string(g.SftUnionPay):
acquirer = m.TradeAcquirerUnionPay
default:
return core.NewBizErr("支付渠道类型异常")
}
var time, err = time.Parse("2006-01-02 15:04:05", *bizData.PayTime)
if err != nil {
return core.NewBizErr("时间解析错误")
}
err = s.Trade.CompleteTradeByNotify(bizData.MchOrderNo, bizData.PayOrderId, &s.TradeSuccessResult{
Acquirer: acquirer,
Actual: decimal.NewFromInt(bizData.Amount).Div(decimal.NewFromInt(100)),
Time: time,
})
if err != nil {
return core.NewServErr("完成交易失败", err)
}
return c.Send([]byte("SUCCESS"))
}
type TradeCallbackSftReq struct {
Code string `json:"code"`
Msg *string `json:"msg"`
Sign *string `json:"sign"`
BizData *string `json:"bizData"`
SignType *string `json:"signType"`
Timestamp string `json:"timestamp"`
}
type TradeCallbackSftReqBizData struct {
MsgType string `json:"msgType"`
PayOrderId string `json:"payOrderId"`
State string `json:"state"`
MercNo string `json:"mercNo"`
StoreId string `json:"storeId"`
MchOrderNo string `json:"mchOrderNo"`
IfCode string `json:"ifCode"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Subject string `json:"subject"`
Body string `json:"body"`
ChannelTradeNo *string `json:"channelTradeNo"`
ChannelSendNo *string `json:"channelSendNo"`
UserId *string `json:"userId"`
ExtParam *string `json:"extParam"`
PayTime *string `json:"payTime"`
Note *string `json:"note"`
PayType *string `json:"payType"`
DrType *string `json:"drType"`
TradeFee *int64 `json:"tradeFee"`
CashFee *int64 `json:"cashFee"`
RefundAmt *int64 `json:"refundAmt"`
RefundState *int32 `json:"refundState"`
SettlementType *string `json:"settlementType"`
} }

View File

@@ -234,38 +234,6 @@ func RemoveUserByAdmin(c *fiber.Ctx) error {
return c.JSON(nil) return c.JSON(nil)
} }
// 管理员更新用户余额
func UpdateUserBalanceByAdmin(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeUserWriteBalance)
if err != nil {
return err
}
var req UpdateUserBalanceByAdminData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
user, err := s.User.Get(q.Q, req.UserID)
if err != nil {
return err
}
balance, err := decimal.NewFromString(req.Balance)
if err != nil {
return err
}
if err := s.User.UpdateBalanceByAdmin(user, balance, &authCtx.Admin.ID); err != nil {
return err
}
return c.JSON(nil)
}
type UpdateUserBalanceByAdminData struct {
UserID int32 `json:"user_id" validate:"required"`
Balance string `json:"balance" validate:"required"`
}
// 绑定管理员 // 绑定管理员
func BindAdmin(c *fiber.Ctx) error { func BindAdmin(c *fiber.Ctx) error {
// 检查权限 // 检查权限
@@ -514,7 +482,7 @@ func UpdateUserBalanceIncByAdmin(c *fiber.Ctx) error {
} }
newBalance := user.Balance.Add(amount) newBalance := user.Balance.Add(amount)
if err := s.User.UpdateBalanceByAdmin(user, newBalance, &authCtx.Admin.ID); err != nil { if err := s.User.UpdateBalanceByAdmin(user, newBalance, req.Remark, &authCtx.Admin.ID); err != nil {
return err return err
} }
@@ -547,7 +515,7 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
} }
newBalance := user.Balance.Sub(amount) newBalance := user.Balance.Sub(amount)
if err := s.User.UpdateBalanceByAdmin(user, newBalance, &authCtx.Admin.ID); err != nil { if err := s.User.UpdateBalanceByAdmin(user, newBalance, req.Remark, &authCtx.Admin.ID); err != nil {
return err return err
} }
@@ -557,4 +525,5 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
type UpdateUserBalanceChangeByAdminData struct { type UpdateUserBalanceChangeByAdminData struct {
UserID int32 `json:"user_id" validate:"required"` UserID int32 `json:"user_id" validate:"required"`
Amount string `json:"amount" validate:"required"` Amount string `json:"amount" validate:"required"`
Remark string `json:"remark" validate:"required"`
} }

View File

@@ -25,11 +25,12 @@ func ApplyMiddlewares(app *fiber.App) {
EnableStackTrace: true, EnableStackTrace: true,
})) }))
// metric // otel
app.Use(otelfiber.Middleware()) app.Use(otelfiber.Middleware())
// logger // logger
app.Use(logger.New(logger.Config{ app.Use(logger.New(logger.Config{
TimeFormat: "2006-01-02 15:04:05",
Next: func(c *fiber.Ctx) bool { Next: func(c *fiber.Ctx) bool {
return c.Path() == "/favicon.ico" return c.Path() == "/favicon.ico"
}, },
@@ -44,12 +45,23 @@ func ApplyMiddlewares(app *fiber.App) {
return err return err
} }
str := ""
if err != nil { if err != nil {
str = err.Error() span.SetAttributes(attribute.String("http.response.error", err.Error()))
}
authCtx := auth.GetAuthCtx(c)
if authCtx != nil {
if authCtx.User != nil {
span.SetAttributes(attribute.Int("auth.user.id", int(authCtx.User.ID)))
}
if authCtx.Admin != nil {
span.SetAttributes(attribute.Int("auth.admin.id", int(authCtx.Admin.ID)))
}
if authCtx.Client != nil {
span.SetAttributes(attribute.Int("auth.client.id", int(authCtx.Client.ID)))
}
} }
span.SetAttributes(attribute.String("http.response.error", str))
return err return err
}) })

View File

@@ -6,6 +6,7 @@ import "platform/web/core"
type Article struct { type Article struct {
core.Model core.Model
GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID
Code *string `json:"code,omitempty" gorm:"column:code"` // 文章编码,用于固定引用链接
Title string `json:"title" gorm:"column:title"` // 文章标题 Title string `json:"title" gorm:"column:title"` // 文章标题
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容 Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序 Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序

View File

@@ -32,6 +32,7 @@ func newArticle(db *gorm.DB, opts ...gen.DOOption) article {
_article.UpdatedAt = field.NewTime(tableName, "updated_at") _article.UpdatedAt = field.NewTime(tableName, "updated_at")
_article.DeletedAt = field.NewField(tableName, "deleted_at") _article.DeletedAt = field.NewField(tableName, "deleted_at")
_article.GroupID = field.NewInt32(tableName, "group_id") _article.GroupID = field.NewInt32(tableName, "group_id")
_article.Code = field.NewString(tableName, "code")
_article.Title = field.NewString(tableName, "title") _article.Title = field.NewString(tableName, "title")
_article.Content = field.NewString(tableName, "content") _article.Content = field.NewString(tableName, "content")
_article.Sort = field.NewInt32(tableName, "sort") _article.Sort = field.NewInt32(tableName, "sort")
@@ -56,6 +57,7 @@ type article struct {
UpdatedAt field.Time UpdatedAt field.Time
DeletedAt field.Field DeletedAt field.Field
GroupID field.Int32 GroupID field.Int32
Code field.String
Title field.String Title field.String
Content field.String Content field.String
Sort field.Int32 Sort field.Int32
@@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article {
a.UpdatedAt = field.NewTime(table, "updated_at") a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at") a.DeletedAt = field.NewField(table, "deleted_at")
a.GroupID = field.NewInt32(table, "group_id") a.GroupID = field.NewInt32(table, "group_id")
a.Code = field.NewString(table, "code")
a.Title = field.NewString(table, "title") a.Title = field.NewString(table, "title")
a.Content = field.NewString(table, "content") a.Content = field.NewString(table, "content")
a.Sort = field.NewInt32(table, "sort") a.Sort = field.NewInt32(table, "sort")
@@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
} }
func (a *article) fillFieldMap() { func (a *article) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 10) a.fieldMap = make(map[string]field.Expr, 11)
a.fieldMap["id"] = a.ID a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["group_id"] = a.GroupID a.fieldMap["group_id"] = a.GroupID
a.fieldMap["code"] = a.Code
a.fieldMap["title"] = a.Title a.fieldMap["title"] = a.Title
a.fieldMap["content"] = a.Content a.fieldMap["content"] = a.Content
a.fieldMap["sort"] = a.Sort a.fieldMap["sort"] = a.Sort

View File

@@ -23,6 +23,7 @@ func ApplyRouters(app *fiber.App) {
// 回调 // 回调
callbacks := app.Group("/callback") callbacks := app.Group("/callback")
callbacks.Get("/identify", handlers.IdentifyCallbackNew) callbacks.Get("/identify", handlers.IdentifyCallbackNew)
callbacks.All("/trade/sft", handlers.TradeCallbackSft)
// 临时 // 临时
if env.RunMode == env.RunModeDev { if env.RunMode == env.RunModeDev {
@@ -66,10 +67,6 @@ func publicRouter(api fiber.Router) {
resource := api.Group("/resource") resource := api.Group("/resource")
resource.Post("/price", handlers.ResourcePrice) resource.Post("/price", handlers.ResourcePrice)
// 交易
trade := api.Group("/trade")
trade.Get("/check", handlers.TradeCheck)
// 前台 // 前台
inquiry := api.Group("/inquiry") inquiry := api.Group("/inquiry")
inquiry.Post("/create", handlers.CreateInquiry) inquiry.Post("/create", handlers.CreateInquiry)
@@ -116,6 +113,7 @@ func userRouter(api fiber.Router) {
// 套餐 // 套餐
resource := api.Group("/resource") resource := api.Group("/resource")
resource.Post("/all", handlers.AllActiveResource) resource.Post("/all", handlers.AllActiveResource)
resource.Post("/list", handlers.PageResource)
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)
@@ -144,6 +142,7 @@ func userRouter(api fiber.Router) {
trade.Post("/create", handlers.TradeCreate) trade.Post("/create", handlers.TradeCreate)
trade.Post("/complete", handlers.TradeComplete) trade.Post("/complete", handlers.TradeComplete)
trade.Post("/cancel", handlers.TradeCancel) trade.Post("/cancel", handlers.TradeCancel)
trade.Post("/finish", handlers.TradeFinish)
// 账单 // 账单
bill := api.Group("/bill") bill := api.Group("/bill")
@@ -171,6 +170,12 @@ func userRouter(api fiber.Router) {
func adminRouter(api fiber.Router) { func adminRouter(api fiber.Router) {
api = api.Group("/admin") api = api.Group("/admin")
// home 后台首页
var home = api.Group("/home")
home.Post("/overview", handlers.HomeOverview)
home.Post("/trend", handlers.HomeTrend)
home.Post("/extractable", handlers.HomeExtractable)
// admin 管理员 // admin 管理员
var admin = api.Group("/admin") var admin = api.Group("/admin")
admin.Post("/all", handlers.AllAdminByAdmin) admin.Post("/all", handlers.AllAdminByAdmin)
@@ -202,12 +207,13 @@ func adminRouter(api fiber.Router) {
user.Post("/remove", handlers.RemoveUserByAdmin) user.Post("/remove", handlers.RemoveUserByAdmin)
user.Post("/update/bind", handlers.BindAdmin) user.Post("/update/bind", handlers.BindAdmin)
user.Post("/update/balance", handlers.UpdateUserBalanceByAdmin)
user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin) user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin)
user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin) user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin)
// resource 套餐 // resource 套餐
var resource = api.Group("/resource") var resource = api.Group("/resource")
resource.Post("/page", handlers.PageResourceByAdmin)
resource.Post("/page/of-user", handlers.PageResourceOfUserByAdmin)
resource.Post("/short/page", handlers.PageResourceShortByAdmin) resource.Post("/short/page", handlers.PageResourceShortByAdmin)
resource.Post("/short/page/of-user", handlers.PageResourceShortOfUserByAdmin) resource.Post("/short/page/of-user", handlers.PageResourceShortOfUserByAdmin)
resource.Post("/long/page", handlers.PageResourceLongByAdmin) resource.Post("/long/page", handlers.PageResourceLongByAdmin)
@@ -241,6 +247,10 @@ func adminRouter(api fiber.Router) {
trade.Post("/page", handlers.PageTradeByAdmin) trade.Post("/page", handlers.PageTradeByAdmin)
trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin) trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin)
trade.Post("/complete", handlers.TradeCompleteByAdmin) trade.Post("/complete", handlers.TradeCompleteByAdmin)
trade.Post("/convert", handlers.TradeConvertByAdmin)
trade.Post("/cancel", handlers.TradeCancelByAdmin)
trade.Post("/update/remark", handlers.TradeUpdateRemarkByAdmin)
trade.Post("/check", handlers.TradeCheck)
// bill 账单 // bill 账单
var bill = api.Group("/bill") var bill = api.Group("/bill")

View File

@@ -14,6 +14,7 @@ import (
"platform/web/core" "platform/web/core"
m "platform/web/models" m "platform/web/models"
q "platform/web/queries" q "platform/web/queries"
"regexp"
"strings" "strings"
"time" "time"
@@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{
"image/webp": ".webp", "image/webp": ".webp",
} }
var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
type ArticleUploadResult struct { type ArticleUploadResult struct {
URL string `json:"url"` URL string `json:"url"`
Path string `json:"path"` Path string `json:"path"`
@@ -151,7 +154,13 @@ func formatUploadSizeLimit(bytes int) string {
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) { func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
do := q.Article.Where() do := q.Article.Where()
if req.Keyword != nil && *req.Keyword != "" { if req.Keyword != nil && *req.Keyword != "" {
do = do.Where(q.Article.Title.Like("%" + *req.Keyword + "%")) do = do.Where(
q.Article.Where(
q.Article.Title.Like("%" + *req.Keyword + "%"),
).Or(
q.Article.Code.Like("%" + *req.Keyword + "%"),
),
)
} }
if req.GroupID != nil { if req.GroupID != nil {
do = do.Where(q.Article.GroupID.Eq(*req.GroupID)) do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
@@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error {
return err return err
} }
return q.Article.Create(&m.Article{ code, err := optionalArticleCode(data.Code)
if err != nil {
return err
}
err = q.Article.Create(&m.Article{
GroupID: data.GroupID, GroupID: data.GroupID,
Code: code,
Title: data.Title, Title: data.Title,
Content: data.Content, Content: data.Content,
Sort: u.Else(data.Sort, 0), Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleStatusEnabled), Status: u.Else(data.Status, m.ArticleStatusEnabled),
}) })
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
return err
} }
type CreateArticleData struct { type CreateArticleData struct {
GroupID int32 `json:"group_id" validate:"required"` GroupID int32 `json:"group_id" validate:"required"`
Code *string `json:"code"`
Title string `json:"title" validate:"required"` Title string `json:"title" validate:"required"`
Content *string `json:"content"` Content *string `json:"content"`
Sort *int32 `json:"sort"` Sort *int32 `json:"sort"`
@@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error {
if data.GroupID != nil { if data.GroupID != nil {
do = append(do, q.Article.GroupID.Value(*data.GroupID)) do = append(do, q.Article.GroupID.Value(*data.GroupID))
} }
if data.Code != nil {
code, err := normalizeArticleCode(*data.Code)
if err != nil {
return err
}
do = append(do, q.Article.Code.Value(code))
}
if data.Title != nil { if data.Title != nil {
do = append(do, q.Article.Title.Value(*data.Title)) do = append(do, q.Article.Title.Value(*data.Title))
} }
@@ -239,6 +266,9 @@ func (s *articleService) Update(data UpdateArticleData) error {
} }
r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...) r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...)
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
if err != nil { if err != nil {
return err return err
} }
@@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error {
type UpdateArticleData struct { type UpdateArticleData struct {
ID int32 `json:"id" validate:"required"` ID int32 `json:"id" validate:"required"`
GroupID *int32 `json:"group_id"` GroupID *int32 `json:"group_id"`
Code *string `json:"code"`
Title *string `json:"title"` Title *string `json:"title"`
Content *string `json:"content"` Content *string `json:"content"`
Sort *int32 `json:"sort"` Sort *int32 `json:"sort"`
@@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
} }
group.Articles = append(group.Articles, &ArticleNavArticle{ group.Articles = append(group.Articles, &ArticleNavArticle{
ID: article.ID, ID: article.ID,
Code: article.Code,
Title: article.Title, Title: article.Title,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
}) })
@@ -331,18 +363,40 @@ type ArticleNavGroup struct {
type ArticleNavArticle struct { type ArticleNavArticle struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"` Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) { type GetArticleReq struct {
article, err := q.Article. ID *int32 `json:"id"`
Code *string `json:"code"`
}
func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) {
do := q.Article.
Preload(q.Article.Group). Preload(q.Article.Group).
Where( Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled)))
q.Article.ID.Eq(id),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)), switch {
). case req.ID != nil && req.Code != nil:
Take() return nil, core.NewBizErr("文档 ID 和编码不能同时提供")
case req.ID != nil:
if *req.ID <= 0 {
return nil, core.NewBizErr("文档 ID 不正确")
}
do = do.Where(q.Article.ID.Eq(*req.ID))
case req.Code != nil:
code, err := normalizeArticleCode(*req.Code)
if err != nil {
return nil, err
}
do = do.Where(q.Article.Code.Eq(code))
default:
return nil, core.NewBizErr("必须提供文档 ID 或编码")
}
article, err := do.Take()
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在") return nil, core.NewBizErr("文档不存在")
} }
@@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
return &ArticlePublicDetail{ return &ArticlePublicDetail{
ID: article.ID, ID: article.ID,
Code: article.Code,
Title: article.Title, Title: article.Title,
Content: article.Content, Content: article.Content,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
@@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
type ArticlePublicDetail struct { type ArticlePublicDetail struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Content *string `json:"content,omitempty"` Content *string `json:"content,omitempty"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error {
} }
return err return err
} }
func optionalArticleCode(code *string) (*string, error) {
if code == nil || strings.TrimSpace(*code) == "" {
return nil, nil
}
normalized, err := normalizeArticleCode(*code)
if err != nil {
return nil, err
}
return &normalized, nil
}
func normalizeArticleCode(code string) (string, error) {
normalized := strings.TrimSpace(code)
if !articleCodePattern.MatchString(normalized) {
return "", core.NewBizErr("文档编码只能包含小写字母、数字、下划线或连字符,长度为 1-64 位")
}
return normalized, nil
}

View File

@@ -136,7 +136,12 @@ func (s *channelServer) RemoveChannels(batch string) error {
return nil return nil
} }
if err := s.provider.removeRemote(batch, usedBatch); err != nil { provider, err := channelProviderByProxyID(usedBatch.ProxyID)
if err != nil {
return err
}
if err := provider.removeRemote(batch, usedBatch); err != nil {
return err return err
} }
if err := freeChans(usedBatch.ProxyID, batch); err != nil { if err := freeChans(usedBatch.ProxyID, batch); err != nil {
@@ -407,6 +412,22 @@ func selectProxyByType(proxyType m.ProxyType, count int) (*m.Proxy, error) {
return bestProxy, nil return bestProxy, nil
} }
func channelProviderByProxyID(proxyID int32) (channelProvider, error) {
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(proxyID)).Take()
if err != nil {
return nil, core.NewServErr("获取代理数据失败", err)
}
switch proxy.Type {
case m.ProxyTypeGost:
return &channelGostProvider{}, nil
case m.ProxyTypeBaiYin:
return &channelBaiyinProvider{}, nil
default:
return nil, core.NewBizErr(fmt.Sprintf("不支持的代理类型: %d", proxy.Type))
}
}
func (s *channelServer) RefreshEdges() error { func (s *channelServer) RefreshEdges() error {
// 仅白银网关支持边缘节点刷新,GOST 不参与此流程。 // 仅白银网关支持边缘节点刷新,GOST 不参与此流程。

View File

@@ -7,7 +7,6 @@ import (
"platform/web/core" "platform/web/core"
g "platform/web/globals" g "platform/web/globals"
m "platform/web/models" m "platform/web/models"
q "platform/web/queries"
) )
type channelBaiyinProvider struct{} type channelBaiyinProvider struct{}
@@ -68,30 +67,31 @@ func (s *channelBaiyinProvider) prepareCreate(ctx *channelCreateContext) (*chann
} }
func (s *channelBaiyinProvider) removeRemote(_ string, batch *usedChanBatch) error { func (s *channelBaiyinProvider) removeRemote(_ string, batch *usedChanBatch) error {
configs := make([]*g.PortConfigsReq, len(batch.Chans))
for i, ch := range batch.Chans {
configs[i] = &g.PortConfigsReq{
Port: int(ch.Port()),
Edge: &[]string{},
AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
Status: false,
}
}
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
if err != nil {
return core.NewServErr("获取代理数据失败", err)
}
gateway, err := proxyGateway(proxy)
if err != nil {
return core.NewServErr("创建代理网关失败", err)
}
if err = gateway.GatewayPortConfigs(configs); err != nil {
return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
}
return nil return nil
// configs := make([]*g.PortConfigsReq, len(batch.Chans))
// for i, ch := range batch.Chans {
// configs[i] = &g.PortConfigsReq{
// Port: int(ch.Port()),
// Edge: &[]string{},
// AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
// Status: false,
// }
// }
// proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
// if err != nil {
// return core.NewServErr("获取代理数据失败", err)
// }
// gateway, err := proxyGateway(proxy)
// if err != nil {
// return core.NewServErr("创建代理网关失败", err)
// }
// if err = gateway.GatewayPortConfigs(configs); err != nil {
// return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
// }
// return nil
} }
// ensureEdges 检查本地节点是否足够,如果不足从云端连入 // ensureEdges 检查本地节点是否足够,如果不足从云端连入

View File

@@ -9,6 +9,7 @@ import (
m "platform/web/models" m "platform/web/models"
q "platform/web/queries" q "platform/web/queries"
"strings" "strings"
"time"
"gorm.io/gen/field" "gorm.io/gen/field"
) )
@@ -51,8 +52,10 @@ func (s *channelGostProvider) prepareCreate(ctx *channelCreateContext) (*channel
Type: "tcp", Type: "tcp",
}, },
Recorders: []g.GostRecorderConfig{ Recorders: []g.GostRecorderConfig{
{Name: "record-file", Record: "recorder.service.handler"}, {Name: "record-http-otel", Record: "recorder.service.handler"},
}, },
Limiter: "limiter-8m",
Bypasses: []string{"china", "deny"},
} }
if ctx.AuthWhitelist { if ctx.AuthWhitelist {
@@ -129,7 +132,12 @@ func (s *channelGostProvider) removeRemote(batchNo string, batch *usedChanBatch)
return client.DeleteAdmission(admissionName) return client.DeleteAdmission(admissionName)
})) }))
} }
if len(deleteErrs) != 0 {
return u.CombineErrors(deleteErrs) return u.CombineErrors(deleteErrs)
}
time.Sleep(time.Millisecond * 200)
return nil
} }
func (s *channelGostProvider) selectProxy(count int) (*m.Proxy, error) { func (s *channelGostProvider) selectProxy(count int) (*m.Proxy, error) {

424
web/services/home.go Normal file
View File

@@ -0,0 +1,424 @@
package services
import (
"fmt"
"platform/web/core"
g "platform/web/globals"
m "platform/web/models"
"time"
"github.com/shopspring/decimal"
)
const homeMaxTrendDays = 366
var Home = &homeService{}
type homeService struct{}
type HomeOverviewResp struct {
PendingCustomerCount int64 `json:"pending_customer_count"`
ActiveChannelCount int64 `json:"active_channel_count"`
TodayTradeAmount string `json:"today_trade_amount"`
TodayExtractCount int64 `json:"today_extract_count"`
TodayNewCustomerCount int64 `json:"today_new_customer_count"`
}
type HomeTrendReq struct {
DateStart string `json:"date_start" validate:"required"`
DateEnd string `json:"date_end" validate:"required"`
}
type HomeTrendResp []HomeTrendItem
type HomeTrendItem struct {
Date string `json:"date"`
ActiveChannelCount int64 `json:"active_channel_count"`
TradeAmount string `json:"trade_amount"`
NewCustomerCount int64 `json:"new_customer_count"`
ExtractCount int64 `json:"extract_count"`
}
type HomeExtractableResp struct {
Short int64 `json:"short"`
Long int64 `json:"long"`
}
type homeRange struct {
StartLocal time.Time
EndLocal time.Time
StartUTC time.Time
EndUTC time.Time
Dates []string
}
type homeCountRow struct {
Date string
Count int64
}
type homeAmountRow struct {
Date string
Amount string
}
func (s *homeService) Overview() (*HomeOverviewResp, error) {
loc := homeLocation()
startLocal := startOfLocalDay(time.Now().In(loc), loc)
rng := homeRange{
StartLocal: startLocal,
EndLocal: startLocal,
StartUTC: startLocal.UTC(),
EndUTC: startLocal.AddDate(0, 0, 1).UTC(),
Dates: []string{homeDateKey(startLocal, loc)},
}
pendingCustomerCount, err := s.pendingCustomerCount()
if err != nil {
return nil, err
}
activeChannelCount, err := s.activeChannelCount(time.Now().UTC())
if err != nil {
return nil, err
}
tradeAmount, err := s.tradeAmount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
extractCount, err := s.extractCount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
newCustomerCount, err := s.newCustomerCount(rng.StartUTC, rng.EndUTC)
if err != nil {
return nil, err
}
return &HomeOverviewResp{
PendingCustomerCount: pendingCustomerCount,
ActiveChannelCount: activeChannelCount,
TodayTradeAmount: tradeAmount,
TodayExtractCount: extractCount,
TodayNewCustomerCount: newCustomerCount,
}, nil
}
func (s *homeService) Trend(req HomeTrendReq) (HomeTrendResp, error) {
rng, err := newHomeRange(req.DateStart, req.DateEnd)
if err != nil {
return nil, err
}
items := make(HomeTrendResp, 0, len(rng.Dates))
byDate := make(map[string]int, len(rng.Dates))
for _, date := range rng.Dates {
byDate[date] = len(items)
items = append(items, HomeTrendItem{
Date: date,
TradeAmount: "0.00",
})
}
activeChannels, err := s.dailyActiveChannelCounts(rng)
if err != nil {
return nil, err
}
for _, row := range activeChannels {
if index, ok := byDate[row.Date]; ok {
items[index].ActiveChannelCount = row.Count
}
}
trades, err := s.dailyTradeAmounts(rng)
if err != nil {
return nil, err
}
for _, row := range trades {
if index, ok := byDate[row.Date]; ok {
items[index].TradeAmount = normalizeDecimalString(row.Amount)
}
}
newCustomers, err := s.dailyNewCustomerCounts(rng)
if err != nil {
return nil, err
}
for _, row := range newCustomers {
if index, ok := byDate[row.Date]; ok {
items[index].NewCustomerCount = row.Count
}
}
extracts, err := s.dailyExtractCounts(rng)
if err != nil {
return nil, err
}
for _, row := range extracts {
if index, ok := byDate[row.Date]; ok {
items[index].ExtractCount = row.Count
}
}
return items, nil
}
func (s *homeService) Extractable() (*HomeExtractableResp, error) {
loc := homeLocation()
now := time.Now().In(loc)
var resources []*m.Resource
err := g.DB.
Preload("Short").
Preload("Long").
Where("active = ?", true).
Find(&resources).Error
if err != nil {
return nil, err
}
resp := &HomeExtractableResp{}
for _, resource := range resources {
switch resource.Type {
case m.ResourceTypeShort:
resp.Short += calcShortExtractable(resource.Short, now, loc)
case m.ResourceTypeLong:
resp.Long += calcLongExtractable(resource.Long, now, loc)
}
}
return resp, nil
}
func (s *homeService) pendingCustomerCount() (int64, error) {
var count int64
err := g.DB.Model(&m.User{}).Where("admin_id IS NULL").Count(&count).Error
return count, err
}
func (s *homeService) activeChannelCount(now time.Time) (int64, error) {
var count int64
err := g.DB.Model(&m.Channel{}).Where("expired_at > ?", now.UTC()).Count(&count).Error
return count, err
}
func (s *homeService) tradeAmount(startUTC, endUTC time.Time) (string, error) {
var amount string
err := g.DB.Raw(`
SELECT COALESCE(SUM(payment), 0)::text
FROM "trade"
WHERE deleted_at IS NULL
AND status = ?
AND completed_at >= ?
AND completed_at < ?
`, int(m.TradeStatusSuccess), startUTC, endUTC).Scan(&amount).Error
return normalizeDecimalString(amount), err
}
func (s *homeService) extractCount(startUTC, endUTC time.Time) (int64, error) {
var count int64
err := g.DB.Raw(`
SELECT COALESCE(SUM(count), 0)
FROM logs_user_usage
WHERE time >= ?
AND time < ?
`, startUTC, endUTC).Scan(&count).Error
return count, err
}
func (s *homeService) newCustomerCount(startUTC, endUTC time.Time) (int64, error) {
var count int64
err := g.DB.Model(&m.User{}).
Where("created_at >= ? AND created_at < ?", startUTC, endUTC).
Count(&count).Error
return count, err
}
func (s *homeService) dailyActiveChannelCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
WITH days AS (
SELECT generate_series(?::timestamptz, ?::timestamptz - interval '1 day', interval '1 day') AS day_start
)
SELECT
to_char(days.day_start AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COUNT(channel.id) AS count
FROM days
LEFT JOIN channel
ON channel.deleted_at IS NULL
AND channel.created_at < days.day_start + interval '1 day'
AND channel.expired_at >= days.day_start
GROUP BY days.day_start
ORDER BY days.day_start
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyTradeAmounts(rng homeRange) ([]homeAmountRow, error) {
var rows []homeAmountRow
err := g.DB.Raw(`
SELECT
to_char(completed_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COALESCE(SUM(payment), 0)::text AS amount
FROM "trade"
WHERE deleted_at IS NULL
AND status = ?
AND completed_at >= ?
AND completed_at < ?
GROUP BY date
ORDER BY date
`, int(m.TradeStatusSuccess), rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyNewCustomerCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
SELECT
to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM "user"
WHERE deleted_at IS NULL
AND created_at >= ?
AND created_at < ?
GROUP BY date
ORDER BY date
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func (s *homeService) dailyExtractCounts(rng homeRange) ([]homeCountRow, error) {
var rows []homeCountRow
err := g.DB.Raw(`
SELECT
to_char(time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
COALESCE(SUM(count), 0) AS count
FROM logs_user_usage
WHERE time >= ?
AND time < ?
GROUP BY date
ORDER BY date
`, rng.StartUTC, rng.EndUTC).Scan(&rows).Error
return rows, err
}
func newHomeRange(dateStart, dateEnd string) (homeRange, error) {
loc := homeLocation()
startLocal, err := time.ParseInLocation(time.DateOnly, dateStart, loc)
if err != nil {
return homeRange{}, core.NewBizErr("date_start 必须是 YYYY-MM-DD 格式", err)
}
endLocal, err := time.ParseInLocation(time.DateOnly, dateEnd, loc)
if err != nil {
return homeRange{}, core.NewBizErr("date_end 必须是 YYYY-MM-DD 格式", err)
}
if endLocal.Before(startLocal) {
return homeRange{}, core.NewBizErr("date_end 不能早于 date_start")
}
days := int(endLocal.Sub(startLocal).Hours()/24) + 1
if days > homeMaxTrendDays {
return homeRange{}, core.NewBizErr(fmt.Sprintf("日期范围不能超过 %d 天", homeMaxTrendDays))
}
dates := make([]string, 0, days)
for day := startLocal; !day.After(endLocal); day = day.AddDate(0, 0, 1) {
dates = append(dates, homeDateKey(day, loc))
}
return homeRange{
StartLocal: startLocal,
EndLocal: endLocal,
StartUTC: startLocal.UTC(),
EndUTC: endLocal.AddDate(0, 0, 1).UTC(),
Dates: dates,
}, nil
}
func calcShortExtractable(resource *m.ResourceShort, now time.Time, loc *time.Location) int64 {
if resource == nil {
return 0
}
return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc)
}
func calcLongExtractable(resource *m.ResourceLong, now time.Time, loc *time.Location) int64 {
if resource == nil {
return 0
}
return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc)
}
func calcExtractable(mode m.ResourceMode, quota, used, daily int32, lastAt, expireAt *time.Time, now time.Time, loc *time.Location) int64 {
switch mode {
case m.ResourceModeQuota:
return maxInt64(int64(quota)-int64(used), 0)
case m.ResourceModeTime:
if expireAt == nil || !expireAt.After(now) {
return 0
}
todayRemaining := int64(quota)
if lastAt != nil && sameLocalDate(*lastAt, now, loc) {
todayRemaining = maxInt64(int64(quota)-int64(daily), 0)
}
todayStart := startOfLocalDay(now, loc)
expireStart := startOfLocalDay(expireAt.In(loc), loc)
futureDays := int64(expireStart.Sub(todayStart).Hours() / 24)
if futureDays < 0 {
futureDays = 0
}
return todayRemaining + futureDays*int64(quota)
}
return 0
}
func homeLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("Asia/Shanghai", 8*60*60)
}
return loc
}
func startOfLocalDay(t time.Time, loc *time.Location) time.Time {
y, m, d := t.In(loc).Date()
return time.Date(y, m, d, 0, 0, 0, 0, loc)
}
func sameLocalDate(a, b time.Time, loc *time.Location) bool {
ay, am, ad := a.In(loc).Date()
by, bm, bd := b.In(loc).Date()
return ay == by && am == bm && ad == bd
}
func homeDateKey(t time.Time, loc *time.Location) string {
return t.In(loc).Format(time.DateOnly)
}
func normalizeDecimalString(s string) string {
if s == "" {
return "0.00"
}
d, err := decimal.NewFromString(s)
if err != nil {
return "0.00"
}
return d.StringFixed(2)
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}

87
web/services/home_test.go Normal file
View File

@@ -0,0 +1,87 @@
package services
import (
m "platform/web/models"
"testing"
"time"
)
func TestNewHomeRange(t *testing.T) {
rng, err := newHomeRange("2026-07-20", "2026-07-21")
if err != nil {
t.Fatalf("newHomeRange returned error: %v", err)
}
if got, want := len(rng.Dates), 2; got != want {
t.Fatalf("len(Dates) = %d, want %d", got, want)
}
if got, want := rng.Dates[0], "2026-07-20"; got != want {
t.Fatalf("Dates[0] = %s, want %s", got, want)
}
if got, want := rng.Dates[1], "2026-07-21"; got != want {
t.Fatalf("Dates[1] = %s, want %s", got, want)
}
if got, want := rng.StartUTC.Format(time.RFC3339), "2026-07-19T16:00:00Z"; got != want {
t.Fatalf("StartUTC = %s, want %s", got, want)
}
if got, want := rng.EndUTC.Format(time.RFC3339), "2026-07-21T16:00:00Z"; got != want {
t.Fatalf("EndUTC = %s, want %s", got, want)
}
}
func TestNewHomeRangeRejectsInvalidRange(t *testing.T) {
_, err := newHomeRange("2026-07-21", "2026-07-20")
if err == nil {
t.Fatal("newHomeRange expected error")
}
}
func TestCalcExtractableQuota(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeQuota, 100, 30, 0, nil, nil, now, loc)
if want := int64(70); got != want {
t.Fatalf("calcExtractable quota = %d, want %d", got, want)
}
got = calcExtractable(m.ResourceModeQuota, 100, 130, 0, nil, nil, now, loc)
if want := int64(0); got != want {
t.Fatalf("calcExtractable exhausted quota = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeCountsTodayAndExpiryDate(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
lastAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc)
expireAt := time.Date(2026, 7, 23, 12, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 30, &lastAt, &expireAt, now, loc)
if want := int64(270); got != want {
t.Fatalf("calcExtractable time = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeResetsDailyWhenLastAtIsNotToday(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
lastAt := time.Date(2026, 7, 20, 23, 59, 59, 0, loc)
expireAt := time.Date(2026, 7, 21, 23, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 80, &lastAt, &expireAt, now, loc)
if want := int64(100); got != want {
t.Fatalf("calcExtractable time reset = %d, want %d", got, want)
}
}
func TestCalcExtractableTimeExpired(t *testing.T) {
loc := homeLocation()
now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc)
expireAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc)
got := calcExtractable(m.ResourceModeTime, 100, 0, 0, nil, &expireAt, now, loc)
if want := int64(0); got != want {
t.Fatalf("calcExtractable expired time = %d, want %d", got, want)
}
}

View File

@@ -6,6 +6,7 @@ import (
q "platform/web/queries" q "platform/web/queries"
"time" "time"
"github.com/shopspring/decimal"
"gorm.io/gen/field" "gorm.io/gen/field"
) )
@@ -20,7 +21,7 @@ func (s *productService) AllProducts() ([]*m.Product, error) {
Find() Find()
} }
func (s *productService) AllProductSaleInfos() ([]*m.Product, error) { func (s *productService) AllProductSaleInfos() ([]any, error) {
products, err := q.Product. products, err := q.Product.
Select( Select(
q.Product.ID, q.Product.ID,
@@ -43,7 +44,17 @@ func (s *productService) AllProductSaleInfos() ([]*m.Product, error) {
pids[i] = p.ID pids[i] = p.ID
} }
skus, err := q.ProductSku. type SkuInfo struct {
ID int32 `json:"id"`
ProductID int32 `json:"product_id"`
Name string `json:"name"`
Code string `json:"code"`
Price decimal.Decimal `json:"price"`
CountMin decimal.Decimal `json:"count_min"`
Discount float64 `json:"discount"`
}
var skus []*SkuInfo
err = q.ProductSku.
Select( Select(
q.ProductSku.ID, q.ProductSku.ID,
q.ProductSku.ProductID, q.ProductSku.ProductID,
@@ -51,29 +62,47 @@ func (s *productService) AllProductSaleInfos() ([]*m.Product, error) {
q.ProductSku.Code, q.ProductSku.Code,
q.ProductSku.Price, q.ProductSku.Price,
q.ProductSku.CountMin, q.ProductSku.CountMin,
q.ProductDiscount.Discount,
). ).
Where( Where(
q.ProductSku.ProductID.In(pids...), q.ProductSku.ProductID.In(pids...),
q.ProductSku.Status.Eq(int32(m.SkuStatusEnabled)), q.ProductSku.Status.Eq(int32(m.SkuStatusEnabled)),
). ).
LeftJoin(q.ProductDiscount, q.ProductDiscount.ID.EqCol(q.ProductSku.DiscountId)).
Order(q.ProductSku.Sort). Order(q.ProductSku.Sort).
Find() Scan(&skus)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pmap := make(map[int32]*m.Product, len(products)) type ProductInfo struct {
m.Product
Skus []*SkuInfo `json:"skus,omitempty"`
}
pmap := make(map[int32]*ProductInfo, len(products))
for _, p := range products { for _, p := range products {
pmap[p.ID] = p pmap[p.ID] = &ProductInfo{Product: *p, Skus: make([]*SkuInfo, 0)}
p.Skus = make([]*m.ProductSku, 0)
} }
for _, s := range skus { for _, s := range skus {
if p, ok := pmap[s.ProductID]; ok { if p, ok := pmap[s.ProductID]; ok {
p.Skus = append(p.Skus, s) p.Skus = append(p.Skus, &SkuInfo{
ID: s.ID,
ProductID: s.ProductID,
Name: s.Name,
Code: s.Code,
Price: s.Price,
CountMin: s.CountMin,
Discount: s.Discount,
})
} }
} }
return products, nil plist := make([]any, 0, len(pmap))
for _, p := range pmap {
plist = append(plist, p)
}
return plist, nil
} }
// 新增产品 // 新增产品

View File

@@ -46,8 +46,8 @@ func rebuildFreeChans(proxyID int32, addr netip.Addr) error {
return err return err
} }
chans := make([]netip.AddrPort, 10000) chans := make([]netip.AddrPort, 30000)
for i := range 10000 { for i := range 30000 {
chans[i] = netip.AddrPortFrom(addr, uint16(i+10000)) chans[i] = netip.AddrPortFrom(addr, uint16(i+10000))
} }

View File

@@ -146,6 +146,7 @@ func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productD
Currency: "cny", Currency: "cny",
ClientIp: "123.52.74.23", ClientIp: "123.52.74.23",
OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")), OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")),
NotifyUrl: &env.SftNotifyUrl,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -204,12 +205,7 @@ func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productD
// 缓存产品数据 // 缓存产品数据
w := bytes.Buffer{} w := bytes.Buffer{}
gob.NewEncoder(&w).Encode(detail) gob.NewEncoder(&w).Encode(detail)
err = g.Redis.Set( err = g.Redis.Set(context.Background(), tradeProductKey(tradeNo), w.Bytes(), 0).Err()
context.Background(),
tradeProductKey(tradeNo),
w.Bytes(),
expireIn,
).Err()
if err != nil { if err != nil {
return nil, core.NewServErr("保存购买信息失败", err) return nil, core.NewServErr("保存购买信息失败", err)
} }
@@ -243,23 +239,47 @@ func (s *tradeService) CompleteTrade(user *m.User, ref *TradeRef) error {
} }
} }
// 获取交易信息
trade, err := q.Trade.
Preload(q.Trade.User).
Where(q.Trade.InnerNo.Eq(ref.TradeNo)).
Take()
if err != nil {
return core.NewBizErr("获取交易信息失败", err)
}
if trade.User == nil || trade.User.ID != user.ID {
return core.NewBizErr("交易信息不存在")
}
// 更新交易状态 // 更新交易状态
err = s.OnCompleteTrade(user, ref.TradeNo, result.TransId, &result.Success) err = s.OnCompleteTrade(trade, ref.TradeNo, result.TransId, &result.Success)
if err != nil { if err != nil {
return core.NewServErr("处理交易失败", err) return core.NewServErr("处理交易失败", err)
} }
return nil return nil
} }
func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo string, result *TradeSuccessResult) error { func (s *tradeService) CompleteTradeByNotify(interNo string, outerNo string, result *TradeSuccessResult) error {
// 获取交易信息
trade, err := q.Trade. trade, err := q.Trade.
Preload(q.Trade.User).
Where(q.Trade.InnerNo.Eq(interNo)). Where(q.Trade.InnerNo.Eq(interNo)).
Take() Take()
if err != nil { if err != nil {
return core.NewBizErr("获取交易信息失败", err) return core.NewBizErr("获取交易信息失败", err)
} }
if trade.User == nil {
return core.NewBizErr("交易信息不存在")
}
err = s.OnCompleteTrade(trade, interNo, outerNo, result)
if err != nil {
return core.NewServErr("处理交易失败", err)
}
return nil
}
func (s *tradeService) OnCompleteTrade(trade *m.Trade, interNo string, outerNo string, result *TradeSuccessResult) error {
user := trade.User
// 检查交易状态 // 检查交易状态
switch trade.Status { switch trade.Status {
@@ -271,7 +291,8 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
} }
// 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型 // 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型
detailBytes, err := g.Redis.Get(context.Background(), tradeProductKey(interNo)).Bytes() tradeKey := tradeProductKey(interNo)
detailBytes, err := g.Redis.Get(context.Background(), tradeKey).Bytes()
if err != nil { if err != nil {
return core.NewServErr("恢复购买信息失败", err) return core.NewServErr("恢复购买信息失败", err)
} }
@@ -344,6 +365,12 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
} }
} }
// 删除缓存
err = g.Redis.Del(context.Background(), tradeKey).Err()
if err != nil {
return core.NewServErr("删除缓存失败", err)
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -353,6 +380,89 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
return nil return nil
} }
// 转换交易
func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) error {
// 检查订单状态
result, err := s.CheckTrade(ref)
if err != nil {
return core.NewServErr("检查订单状态失败", err)
}
if result.Status != m.TradeStatusSuccess {
switch result.Status {
case m.TradeStatusPending:
return core.NewBizErr("订单未支付")
case m.TradeStatusCanceled:
return core.NewBizErr("订单已过期")
}
}
// 获取交易信息
trade, err := q.Trade.
Where(q.Trade.InnerNo.Eq(ref.TradeNo)).
Take()
if err != nil {
return core.NewBizErr("获取交易信息失败", err)
}
// 检查交易状态
switch trade.Status {
case m.TradeStatusCanceled:
return core.NewBizErr("交易已取消")
case m.TradeStatusSuccess:
return nil // 跳过更新交易信息
case m.TradeStatusPending:
}
// 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型
tradeKey := tradeProductKey(ref.TradeNo)
detailBytes, err := g.Redis.Get(context.Background(), tradeKey).Bytes()
if err != nil {
return core.NewServErr("恢复购买信息失败", err)
}
var detail TradeDetail
r := bytes.NewReader(detailBytes)
if err := gob.NewDecoder(r).Decode(&detail); err != nil {
return core.NewServErr("解析购买信息失败", err)
}
user, err := q.User.Where(q.User.ID.Eq(trade.UserID)).First()
if err != nil {
return err
}
return q.Q.Transaction(func(q *q.Query) error {
// 更新交易信息
r, err := q.Trade.
Where(
q.Trade.InnerNo.Eq(ref.TradeNo),
q.Trade.Status.Eq(int(m.TradeStatusPending)),
).
UpdateSimple(
q.Trade.Status.Value(int(m.TradeStatusSuccess)),
q.Trade.OuterNo.Value(result.TransId),
q.Trade.Payment.Value(result.Success.Actual),
q.Trade.Acquirer.Value(int(result.Success.Acquirer)),
q.Trade.CompletedAt.Value(result.Success.Time),
)
if err != nil {
return core.NewServErr("更新交易信息失败", err)
}
if r.RowsAffected == 0 {
return core.NewBizErr("交易状态已过期")
}
// 更新用户余额
if err := User.UpdateBalance(q, user, detail.Actual, "管理员订单补余额", &admin.ID, nil); err != nil {
return err
}
return nil
})
}
// 取消交易 // 取消交易
func (s *tradeService) CancelTrade(ref *TradeRef) error { func (s *tradeService) CancelTrade(ref *TradeRef) error {
now := time.Now() now := time.Now()
@@ -394,7 +504,7 @@ func (s *tradeService) CancelTrade(ref *TradeRef) error {
}) })
if err != nil { if err != nil {
slog.Debug(fmt.Sprintf("订单无需关闭: %s", err.Error())) slog.Debug(fmt.Sprintf("订单无需关闭: %s", err.Error()))
return nil return ErrTradeStatusIgnored
} }
default: default:
@@ -575,6 +685,12 @@ func (s *tradeService) CheckTrade(ref *TradeRef) (*CheckTradeResult, error) {
return &result, nil return &result, nil
} }
// 更新备注
func (s *tradeService) UpdateRemark(tradeNo string, remark string) error {
_, err := q.Trade.Where(q.Trade.InnerNo.Eq(tradeNo)).UpdateColumn(q.Trade.Remark, remark)
return err
}
func tradeProductKey(no string) string { func tradeProductKey(no string) string {
return fmt.Sprintf("trade:%s:product", no) return fmt.Sprintf("trade:%s:product", no)
} }
@@ -639,4 +755,5 @@ func (e TradeErr) Error() string {
var ( var (
ErrTransactionNotSupported = core.NewBizErr("不支持的支付方式") ErrTransactionNotSupported = core.NewBizErr("不支持的支付方式")
ErrTradeStatusIgnored = core.NewBizErr("交易状态已忽略")
) )

View File

@@ -28,7 +28,7 @@ func (s *userService) Get(q *q.Query, uid int32) (*m.User, error) {
return user, nil return user, nil
} }
func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Decimal, adminId *int32) error { func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Decimal, remark string, adminId *int32) error {
if user == nil { if user == nil {
return core.NewServErr("用户不存在") return core.NewServErr("用户不存在")
} }
@@ -39,14 +39,14 @@ func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Deci
} }
return q.Q.Transaction(func(q *q.Query) error { return q.Q.Transaction(func(q *q.Query) error {
return s.UpdateBalance(q, user, amount, "管理员修改余额", adminId, nil) return s.UpdateBalance(q, user, amount, remark, adminId, nil)
}) })
} }
func (s *userService) UpdateBalance(q *q.Query, user *m.User, amount decimal.Decimal, remark string, adminId *int32, billId *int32) error { func (s *userService) UpdateBalance(q *q.Query, user *m.User, amount decimal.Decimal, remark string, adminId *int32, billId *int32) error {
balance := user.Balance.Add(amount) balance := user.Balance.Add(amount)
if balance.IsNegative() { if balance.IsNegative() {
return core.NewServErr("用户余额不足") return core.NewBizErr("用户余额不足")
} }
// 更新余额 // 更新余额

View File

@@ -74,7 +74,7 @@ func (s *verifierService) SendSms(ctx context.Context, phone string, purpose Ver
} }
if response.Body.Code == nil || *response.Body.Code != "OK" { if response.Body.Code == nil || *response.Body.Code != "OK" {
_ = g.Redis.Del(ctx, key, keyLock).Err() _ = g.Redis.Del(ctx, key, keyLock).Err()
return VerifierServiceError("验证码发送失败") return VerifierServiceError(fmt.Sprintf("验证码发送失败: %s", *response.Body.Message))
} }
} }

View File

@@ -3,10 +3,11 @@ package tasks
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"platform/web/events" "platform/web/events"
q "platform/web/queries" m "platform/web/models"
s "platform/web/services" s "platform/web/services"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
@@ -24,20 +25,31 @@ func HandleCompleteTrade(_ context.Context, task *asynq.Task) error {
Method: event.Method, Method: event.Method,
} }
// 尝试完成交易 // 关闭交易
user, err := s.User.Get(q.Q, event.UserId) err := s.Trade.CancelTrade(&data)
if errors.Is(err, s.ErrTradeStatusIgnored) {
result, err := s.Trade.CheckTrade(&data)
if err != nil { if err != nil {
return fmt.Errorf("获取用户失败: %w", err) return fmt.Errorf("检查交易状态失败: %w", err)
} }
if err := s.Trade.CompleteTrade(user, &data); err != nil { switch result.Status {
slog.Debug("结束交易失败:完成交易失败", "err", err) case m.TradeStatusSuccess:
if err := s.Trade.UpdateRemark(data.TradeNo, "已付款"); err != nil {
slog.Error("添加备注失败", "err", err)
}
case m.TradeStatusCanceled:
slog.Debug("交易已取消", "status", result.Status)
default:
return fmt.Errorf("意外交易状态: %v", result.Status)
}
// 交易无法完成,关闭交易 return nil
if err := s.Trade.CancelTrade(&data); err != nil { }
if err != nil {
return fmt.Errorf("结束交易失败:取消交易失败: %w", err) return fmt.Errorf("结束交易失败:取消交易失败: %w", err)
} }
}
return nil return nil
} }
@@ -56,9 +68,9 @@ func HandleRemoveChannel(_ context.Context, task *asynq.Task) (err error) {
func HandleRefreshEdges(_ context.Context, task *asynq.Task) (err error) { func HandleRefreshEdges(_ context.Context, task *asynq.Task) (err error) {
slog.Info("[event]刷新边缘节点") slog.Info("[event]刷新边缘节点")
err = s.Channel.RefreshEdges() // err = s.Channel.RefreshEdges()
if err != nil { // if err != nil {
return fmt.Errorf("刷新边缘节点失败: %w", err) // return fmt.Errorf("刷新边缘节点失败: %w", err)
} // }
return nil return nil
} }

View File

@@ -42,10 +42,6 @@ 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()
} }
@@ -111,29 +107,6 @@ func RunTask(ctx context.Context) error {
return nil return nil
} }
func RunCron(ctx context.Context) error {
cron := asynq.NewSchedulerFromRedisClient(deps.Redis, &asynq.SchedulerOpts{
Logger: &AppAsynqLogger{},
Location: time.Local,
})
cron.Register("0/10 * * * *", events.NewRefreshEdge())
// 停止服务
go func() {
<-ctx.Done()
cron.Shutdown()
}()
// 启动服务
err := cron.Run()
if err != nil {
return fmt.Errorf("定时任务服务运行失败: %w", err)
}
return nil
}
type AppAsynqLogger struct{} type AppAsynqLogger struct{}
func (l *AppAsynqLogger) Debug(args ...any) { func (l *AppAsynqLogger) Debug(args ...any) {