9 Commits

Author SHA1 Message Date
d552f3bc67 新增黑名单过滤 2026-10-15 16:33:03 +08:00
a456686551 延迟释放端口 2026-09-17 13:42:37 +08:00
2f0c6e6cb7 新增 trace attr 2026-09-17 13:33:58 +08:00
5103c08c2b 首页接口实现 2026-07-28 13:18:45 +08:00
83d38956ca 固定文章编码 2026-07-21 15:30:33 +08:00
d961538027 完善支付流程,实现异步事件 2026-07-14 18:11:31 +08:00
5d00111887 修复商福通接口问题 & 新增套餐统一查询接口 2026-07-06 18:09:01 +08:00
3de4947207 完善订单兜底处理 2026-06-26 15:30:30 +08:00
f42e5616b4 提供转余额操作接口 & 网关端口默认到 30000 2026-06-24 14:41:52 +08:00
27 changed files with 2000 additions and 256 deletions

View File

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

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/shopspring/decimal v1.4.0
github.com/smartwalle/alipay/v3 v3.2.28
github.com/valyala/fasthttp v1.68.0
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
go.opentelemetry.io/otel v1.43.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/tjfoc/gmsm v1.4.1 // 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/contrib v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect

View File

@@ -251,6 +251,10 @@ 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);
-- 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 子权限
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),

View File

@@ -175,6 +175,7 @@ drop table if exists article cascade;
create table article (
id int generated by default as identity primary key,
group_id int not null,
code text,
title text not null,
content text,
sort int not null default 0,
@@ -183,6 +184,7 @@ create table article (
updated_at timestamptz default current_timestamp,
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_status on article (status) 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 column article.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.content is '文章内容';
comment on column article.sort is '文章排序';

View File

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

View File

@@ -90,6 +90,7 @@ type GostServiceConfig struct {
Listener GostListenerConfig `json:"listener"`
Recorders []GostRecorderConfig `json:"recorders,omitempty"`
Limiter string `json:"limiter,omitempty"`
Bypasses []string `json:"bypasses,omitempty"`
}
type GostHandlerConfig struct {

View File

@@ -443,3 +443,11 @@ const (
SftRefundIng SftTradeState = "REFUND_ING"
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
}
var req core.IdReq
var req s.GetArticleReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
article, err := s.Article.GetPublic(req.Id)
article, err := s.Article.GetPublic(req)
if err != nil {
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"
)
// 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 分页查询当前用户短效套餐
func PageResourceShort(c *fiber.Ctx) error {
// 检查权限
@@ -209,6 +379,148 @@ type PageResourceLongReq struct {
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 分页查询全部短效套餐
func PageResourceShortByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead)
@@ -393,6 +705,145 @@ type PageResourceLongByAdminReq struct {
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 分页查询指定用户的短效套餐
func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser)

View File

@@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"log/slog"
"platform/web/auth"
"platform/web/core"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/shopspring/decimal"
)
// PageTradeByAdmin 分页查询所有订单
@@ -351,6 +353,24 @@ func TradeCancel(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
// 管理员取消订单
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)
}
// ============================================================
// 结束订单:完成或取消订单
@@ -411,3 +431,99 @@ func TradeCheck(c *fiber.Ctx) error {
return c.JSON(result)
}
// ============================================================
// 交易回调
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)
}
// 管理员更新用户余额
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 {
// 检查权限
@@ -514,7 +482,7 @@ func UpdateUserBalanceIncByAdmin(c *fiber.Ctx) error {
}
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
}
@@ -547,7 +515,7 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
}
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
}
@@ -557,4 +525,5 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
type UpdateUserBalanceChangeByAdminData struct {
UserID int32 `json:"user_id" 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,
}))
// metric
// otel
app.Use(otelfiber.Middleware())
// logger
app.Use(logger.New(logger.Config{
TimeFormat: "2006-01-02 15:04:05",
Next: func(c *fiber.Ctx) bool {
return c.Path() == "/favicon.ico"
},
@@ -44,12 +45,23 @@ func ApplyMiddlewares(app *fiber.App) {
return err
}
str := ""
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
})

View File

@@ -6,6 +6,7 @@ import "platform/web/core"
type Article struct {
core.Model
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"` // 文章标题
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
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.DeletedAt = field.NewField(tableName, "deleted_at")
_article.GroupID = field.NewInt32(tableName, "group_id")
_article.Code = field.NewString(tableName, "code")
_article.Title = field.NewString(tableName, "title")
_article.Content = field.NewString(tableName, "content")
_article.Sort = field.NewInt32(tableName, "sort")
@@ -56,6 +57,7 @@ type article struct {
UpdatedAt field.Time
DeletedAt field.Field
GroupID field.Int32
Code field.String
Title field.String
Content field.String
Sort field.Int32
@@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article {
a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at")
a.GroupID = field.NewInt32(table, "group_id")
a.Code = field.NewString(table, "code")
a.Title = field.NewString(table, "title")
a.Content = field.NewString(table, "content")
a.Sort = field.NewInt32(table, "sort")
@@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
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["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["group_id"] = a.GroupID
a.fieldMap["code"] = a.Code
a.fieldMap["title"] = a.Title
a.fieldMap["content"] = a.Content
a.fieldMap["sort"] = a.Sort

View File

@@ -23,6 +23,7 @@ func ApplyRouters(app *fiber.App) {
// 回调
callbacks := app.Group("/callback")
callbacks.Get("/identify", handlers.IdentifyCallbackNew)
callbacks.All("/trade/sft", handlers.TradeCallbackSft)
// 临时
if env.RunMode == env.RunModeDev {
@@ -112,6 +113,7 @@ func userRouter(api fiber.Router) {
// 套餐
resource := api.Group("/resource")
resource.Post("/all", handlers.AllActiveResource)
resource.Post("/list", handlers.PageResource)
resource.Post("/list/short", handlers.PageResourceShort)
resource.Post("/list/long", handlers.PageResourceLong)
resource.Post("/create", handlers.CreateResource)
@@ -168,6 +170,12 @@ func userRouter(api fiber.Router) {
func adminRouter(api fiber.Router) {
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 管理员
var admin = api.Group("/admin")
admin.Post("/all", handlers.AllAdminByAdmin)
@@ -199,12 +207,13 @@ func adminRouter(api fiber.Router) {
user.Post("/remove", handlers.RemoveUserByAdmin)
user.Post("/update/bind", handlers.BindAdmin)
user.Post("/update/balance", handlers.UpdateUserBalanceByAdmin)
user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin)
user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin)
// 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/of-user", handlers.PageResourceShortOfUserByAdmin)
resource.Post("/long/page", handlers.PageResourceLongByAdmin)
@@ -238,6 +247,8 @@ func adminRouter(api fiber.Router) {
trade.Post("/page", handlers.PageTradeByAdmin)
trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin)
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)

View File

@@ -14,6 +14,7 @@ import (
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"regexp"
"strings"
"time"
@@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{
"image/webp": ".webp",
}
var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
type ArticleUploadResult struct {
URL string `json:"url"`
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) {
do := q.Article.Where()
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 {
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
@@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error {
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,
Code: code,
Title: data.Title,
Content: data.Content,
Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleStatusEnabled),
})
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
return err
}
type CreateArticleData struct {
GroupID int32 `json:"group_id" validate:"required"`
Code *string `json:"code"`
Title string `json:"title" validate:"required"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
@@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error {
if data.GroupID != nil {
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 {
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...)
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
if err != nil {
return err
}
@@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error {
type UpdateArticleData struct {
ID int32 `json:"id" validate:"required"`
GroupID *int32 `json:"group_id"`
Code *string `json:"code"`
Title *string `json:"title"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
@@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
}
group.Articles = append(group.Articles, &ArticleNavArticle{
ID: article.ID,
Code: article.Code,
Title: article.Title,
UpdatedAt: article.UpdatedAt,
})
@@ -331,18 +363,40 @@ type ArticleNavGroup struct {
type ArticleNavArticle struct {
ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
article, err := q.Article.
type GetArticleReq struct {
ID *int32 `json:"id"`
Code *string `json:"code"`
}
func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) {
do := q.Article.
Preload(q.Article.Group).
Where(
q.Article.ID.Eq(id),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
).
Take()
Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled)))
switch {
case req.ID != nil && req.Code != nil:
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) {
return nil, core.NewBizErr("文档不存在")
}
@@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
return &ArticlePublicDetail{
ID: article.ID,
Code: article.Code,
Title: article.Title,
Content: article.Content,
UpdatedAt: article.UpdatedAt,
@@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
type ArticlePublicDetail struct {
ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"`
Content *string `json:"content,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
@@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error {
}
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

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

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

@@ -46,8 +46,8 @@ func rebuildFreeChans(proxyID int32, addr netip.Addr) error {
return err
}
chans := make([]netip.AddrPort, 10000)
for i := range 10000 {
chans := make([]netip.AddrPort, 30000)
for i := range 30000 {
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",
ClientIp: "123.52.74.23",
OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")),
NotifyUrl: &env.SftNotifyUrl,
})
if err != nil {
return nil, err
@@ -238,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 {
return core.NewServErr("处理交易失败", err)
}
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.
Preload(q.Trade.User).
Where(q.Trade.InnerNo.Eq(interNo)).
Take()
if err != nil {
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 {
@@ -357,9 +382,49 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
// 转换交易
func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) error {
trade, err := q.Trade.Where(q.Trade.InnerNo.Eq(ref.TradeNo)).First()
// 检查订单状态
result, err := s.CheckTrade(ref)
if err != nil {
return err
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()
@@ -368,7 +433,33 @@ func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) erro
}
return q.Q.Transaction(func(q *q.Query) error {
return User.UpdateBalance(q, user, trade.Payment, "管理员订单补余额", &admin.ID, nil)
// 更新交易信息
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
})
}

View File

@@ -28,7 +28,7 @@ func (s *userService) Get(q *q.Query, uid int32) (*m.User, error) {
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 {
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 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 {
balance := user.Balance.Add(amount)
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" {
_ = g.Redis.Del(ctx, key, keyLock).Err()
return VerifierServiceError("验证码发送失败")
return VerifierServiceError(fmt.Sprintf("验证码发送失败: %s", *response.Body.Message))
}
}