Files

1478 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package open
import (
"context"
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/open/v1"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/middleware"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/service"
"github.com/qianfree/team-api/internal/utility/crypto"
)
type sOpen struct{}
func getProjectApiKeyPrefixes(ctx context.Context, tenantID, projectID int64) ([]string, error) {
type keyRow struct {
KeyPrefix string `json:"key_prefix"`
}
var keys []keyRow
err := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", projectID).
Fields("key_prefix").
Scan(&keys)
if err != nil {
return nil, err
}
prefixes := make([]string, 0, len(keys))
for _, key := range keys {
if key.KeyPrefix != "" {
prefixes = append(prefixes, key.KeyPrefix)
}
}
return prefixes, nil
}
func invalidateApiKeyPrefixes(ctx context.Context, prefixes []string) {
for _, prefix := range prefixes {
relay.InvalidateApiKey(ctx, prefix)
}
}
func New() *sOpen {
return &sOpen{}
}
func init() {
service.RegisterOpen(New())
}
// ============================================================
// 成员管理
// ============================================================
func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) (*v1.OpenMemberListRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID)
if req.Keyword != "" {
kw := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("username LIKE ? OR email LIKE ?", kw, kw)
}
if req.Role != "" {
m = m.Where("role", req.Role)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
var users []struct {
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
CreatedAt string `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").Page(page, pageSize).Scan(&users)
if err != nil {
return nil, err
}
items := make([]v1.OpenMemberItem, len(users))
for i, u := range users {
items[i] = v1.OpenMemberItem{
ID: u.Id,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
}
}
return &v1.OpenMemberListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateReq) (*v1.OpenMemberCreateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
return nil, err
}
// 团队开关与席位上限:控制台的 InviteMember 两者都校验,开放平台此前一项都没有,
// 于是买 10 个席位的租户可以从这里建出任意多成员,套餐限制被整条绕开。
if err := requireOpenTeamEnabled(ctx, tenantID); err != nil {
return nil, err
}
if err := requireOpenMemberSeat(ctx, tenantID); err != nil {
return nil, err
}
count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count()
if count > 0 {
return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在")
}
passwordHash, err := crypto.HashPassword(req.Password)
if err != nil {
return nil, err
}
result, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: req.Username,
Email: req.Email,
PasswordHash: passwordHash,
Role: req.Role,
DisplayName: req.DisplayName,
Status: "active",
}).Insert()
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return &v1.OpenMemberCreateRes{ID: id}, nil
}
func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateReq) (*v1.OpenMemberUpdateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
return nil, err
}
// 必须先取目标成员的角色:同文件的 OpenMemberDelete 挡了 owner,这里此前没挡。
// 请求校验 v:"in:admin,member" 只拦住了「把别人提升为 owner」,没拦住「把 owner 降为
// member」或「把 owner 置为 disabled」—— 而租户 admin 本就能建带 members:write 的应用,
// 于是 admin 可以经由开放平台把组织所有者踢出去,控制台的四处 owner 保护全被绕过。
var target *struct {
Role string `json:"role"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("role").
Scan(&target)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if target == nil {
return nil, errOpen(consts.CodeNotFound, "用户不存在")
}
data := do.TntUsers{}
hasUpdate := false
roleChanged := false
if req.Role != nil {
if target.Role == "owner" {
return nil, errOpen(consts.CodeForbidden, "不能修改所有者的角色")
}
data.Role = *req.Role
hasUpdate = true
roleChanged = *req.Role != target.Role
}
if req.DisplayName != nil {
data.DisplayName = *req.DisplayName
hasUpdate = true
}
if req.Status != nil {
if target.Role == "owner" && *req.Status != "active" {
return nil, errOpen(consts.CodeForbidden, "不能禁用组织所有者")
}
data.Status = *req.Status
hasUpdate = true
}
if !hasUpdate {
return nil, nil
}
if _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update(); err != nil {
return nil, err
}
// 与控制台 UpdateMemberRole 对齐:角色变更或停用后撤销该成员的活跃会话。
// 租户角色本就每请求从库重读,这一步是纵深防御 —— 失败只记日志,不回滚已生效的变更。
if roleChanged || (req.Status != nil && *req.Status != "active") {
if revokeErr := common.RevokeAllSessions(ctx, "tenant", req.Id); revokeErr != nil {
g.Log().Warningf(ctx, "[OpenMemberUpdate] 撤销成员会话失败 user=%d: %v", req.Id, revokeErr)
}
}
return nil, nil
}
func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
return nil, err
}
var user *struct {
Role string `json:"role"`
}
err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Scan(&user)
if err != nil {
return nil, err
}
if user == nil {
return nil, errOpen(consts.CodeNotFound, "用户不存在")
}
if user.Role == "owner" {
return nil, errOpen(consts.CodeForbidden, "不能删除所有者")
}
_, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
return nil, err
}
// ============================================================
// 成员额度 & 模型
// ============================================================
func (s *sOpen) OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq) (*v1.OpenMemberQuotaRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
return nil, err
}
var user *struct {
QuotaType string `json:"quota_type"`
QuotaLimit float64 `json:"quota_limit"`
QuotaUsed float64 `json:"quota_used"`
QuotaPeriod string `json:"quota_period"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("quota_type, quota_limit, quota_used, quota_period").
Scan(&user)
if err != nil {
return nil, err
}
if user == nil {
return nil, errOpen(consts.CodeNotFound, "用户不存在")
}
return &v1.OpenMemberQuotaRes{
QuotaType: user.QuotaType,
QuotaLimit: user.QuotaLimit,
QuotaUsed: user.QuotaUsed,
Period: user.QuotaPeriod,
}, nil
}
func (s *sOpen) OpenMemberQuotaUpdate(ctx context.Context, req *v1.OpenMemberQuotaUpdateReq) (*v1.OpenMemberQuotaUpdateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
return nil, err
}
if req.QuotaType == "periodic" && req.Period == "" {
return nil, errOpen(consts.CodeBadRequest, "periodic 类型额度必须指定周期")
}
data := do.TntUsers{
QuotaType: req.QuotaType,
QuotaUsed: 0,
}
if req.QuotaType != "none" {
data.QuotaLimit = req.QuotaLimit
} else {
data.QuotaLimit = 0
}
if req.QuotaType == "periodic" {
data.QuotaPeriod = req.Period
data.QuotaResetAt = gtime.Now()
}
_, err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Data(data).
Update()
return nil, err
}
func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsReq) (*v1.OpenMemberModelsRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
return nil, err
}
var scopes []struct {
ModelID string `json:"model_id" orm:"model_id"`
}
err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", req.Id).
Scan(&scopes)
if err != nil {
return nil, err
}
modelIDs := make([]string, 0, len(scopes))
for _, s := range scopes {
modelIDs = append(modelIDs, s.ModelID)
}
return &v1.OpenMemberModelsRes{List: modelIDs}, nil
}
func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberModelsUpdateReq) (*v1.OpenMemberModelsUpdateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
return nil, err
}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", req.Id).
Delete()
if err != nil {
return err
}
if len(req.ModelIDs) > 0 {
for _, modelID := range req.ModelIDs {
if modelID == 0 {
continue
}
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: modelID,
}).Insert()
if err != nil {
return err
}
}
} else {
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: -1,
}).Insert()
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
// 失效该成员的模型范围缓存(生产者在 relay.provider,统一经 relay 入口失效,
// 不在此重复声明同前缀缓存,避免「同名前缀」被误判为命名冲突)。
relay.InvalidateMemberModelScopeCache(ctx, tenantID, req.Id)
return nil, nil
}
// ============================================================
// API Key 管理
// ============================================================
func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.OpenKeyListRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "keys:read"); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.ApiKeys.Ctx(ctx).Where("tenant_id", tenantID)
if req.Status != "" {
m = m.Where("status", req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
var keys []struct {
Id int64 `json:"id" orm:"id"`
Name string `json:"name" orm:"name"`
KeyPrefix string `json:"key_prefix" orm:"key_prefix"`
Status string `json:"status" orm:"status"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").Page(page, pageSize).Scan(&keys)
if err != nil {
return nil, err
}
items := make([]v1.OpenKeyItem, len(keys))
for i, k := range keys {
items[i] = v1.OpenKeyItem{
ID: k.Id,
Name: k.Name,
Key: k.KeyPrefix + "***",
Status: k.Status,
}
if k.CreatedAt != nil {
items[i].CreatedAt = k.CreatedAt.Format("Y-m-d H:i:s")
}
}
return &v1.OpenKeyListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v1.OpenKeyCreateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "keys:write"); err != nil {
return nil, err
}
// 权限范围此前被硬编码为 "full" —— 开放平台签发的每一把 Key 都是最大权限,
// 应用方连「只发一把 chat_only 的 Key」都做不到。改为按请求取值(默认仍是 full,
// 保持既有调用方行为不变),取值集合与 OpenProjectKeyCreate 对齐。
scope := req.Scope
if scope == "" {
scope = "full"
}
// project_id 可选,填写时必须是本租户下的活跃项目。
// relay 的项目状态闸门是 `if k.ProjectID > 0`,只有真正写入 project_id,项目归档/停用
// 才会连带吊销这把 Key;此前这里恒不写 project_id,签出的 Key 完全不受项目生命周期约束。
// 不填则是租户级集成密钥(无项目、无成员归属),仍按 key_type=project 存储 —— 控制台
// 的项目密钥列表正是按 key_type 过滤,改成 personal 反而会因缺少 user_id 而在界面上
// 彻底消失、无法吊销。
if req.ProjectID > 0 {
project, perr := s.getProjectOrError(ctx, req.ProjectID, tenantID)
if perr != nil {
return nil, perr
}
if project.Status != "active" {
return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用")
}
}
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
if err != nil {
return nil, err
}
quotaLimit := req.QuotaLimit
if quotaLimit < 0 {
quotaLimit = 0
}
quotaLimitDecimal := billing.NewFromFloat(quotaLimit)
insertData := do.ApiKeys{
TenantId: tenantID,
Name: req.Name,
EncryptedKey: encryptedKey,
KeyPrefix: prefix,
Scope: scope,
Status: "active",
TotalQuota: &quotaLimitDecimal,
KeyType: "project",
}
if req.ProjectID > 0 {
insertData.ProjectId = req.ProjectID
}
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
for _, modelName := range req.ModelScopes {
if modelName != "" {
if _, err = dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
ApiKeyId: id,
ModelName: modelName,
}); err != nil {
return nil, gerror.Wrapf(err, "设置模型范围失败")
}
}
}
return &v1.OpenKeyCreateRes{ID: id, Key: rawKey}, nil
}
func (s *sOpen) OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v1.OpenKeyDeleteRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "keys:write"); err != nil {
return nil, err
}
var key *struct {
KeyPrefix string `json:"key_prefix"`
}
err := dao.ApiKeys.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("key_prefix").
Scan(&key)
if err != nil {
return nil, err
}
if key == nil {
return nil, errOpen(consts.CodeNotFound, "密钥不存在")
}
_, err = dao.ApiKeys.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
if err == nil {
relay.InvalidateApiKey(ctx, key.KeyPrefix)
}
return nil, err
}
// ============================================================
// 用量查询
// ============================================================
func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (*v1.OpenUsageQueryRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "usage:read"); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.BilUsageLogs.Ctx(ctx).
As("u").
Where("u.tenant_id", tenantID).
Where("u.created_at >= ?", req.StartDate+" 00:00:00").
Where("u.created_at <= ?", req.EndDate+" 23:59:59")
selectFields := ""
groupBy := ""
switch req.GroupBy {
case "model":
selectFields = "u.model_name, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "u.model_name"
case "key":
m = m.LeftJoin("api_keys", "ak", "u.api_key_id = ak.id AND u.tenant_id = ak.tenant_id")
selectFields = "COALESCE(ak.key_prefix, '') as key_name, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "COALESCE(ak.key_prefix, '')"
default:
selectFields = "DATE(u.created_at) as date, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "DATE(u.created_at)"
}
type usageRow struct {
Date string `json:"date"`
Model string `json:"model_name"`
KeyName string `json:"key_name"`
RequestCount int64 `json:"request_count"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
}
total, err := m.Group(groupBy).Count()
if err != nil {
return nil, err
}
var rows []usageRow
err = m.Fields(selectFields).Group(groupBy).OrderDesc(groupBy).Page(page, pageSize).Scan(&rows)
if err != nil {
return nil, err
}
items := make([]v1.OpenUsageItem, 0, len(rows))
for _, r := range rows {
item := v1.OpenUsageItem{
RequestCount: r.RequestCount,
PromptTokens: r.PromptTokens,
CompletionTokens: r.CompletionTokens,
TotalTokens: r.TotalTokens,
Cost: fmt.Sprintf("%.6f", r.Cost),
}
switch req.GroupBy {
case "model":
item.Model = r.Model
case "key":
item.KeyName = r.KeyName
default:
item.Date = r.Date
}
items = append(items, item)
}
return &v1.OpenUsageQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// ============================================================
// 费用查询
// ============================================================
func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryReq) (*v1.OpenBillingQueryRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "billing:read"); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.BilRecords.Ctx(ctx).Where("tenant_id", tenantID)
m = m.Where("created_at >= ?", req.StartDate+" 00:00:00")
m = m.Where("created_at <= ?", req.EndDate+" 23:59:59")
total, err := m.Count()
if err != nil {
return nil, err
}
type billingRow struct {
Id int64 `json:"id"`
Type string `json:"type"`
TotalCost float64 `json:"total_cost"`
Balance float64 `json:"balance"`
ModelName string `json:"model_name"`
CreatedAt *gtime.Time `json:"created_at"`
}
var records []billingRow
err = m.Fields("id, relay_mode as type, total_cost, 0 as balance, model_name, created_at").
OrderDesc("created_at").
Page(page, pageSize).
Scan(&records)
if err != nil {
return nil, err
}
items := make([]v1.OpenBillingItem, 0, len(records))
for _, r := range records {
item := v1.OpenBillingItem{
ID: r.Id,
Type: r.Type,
Amount: fmt.Sprintf("%.6f", r.TotalCost),
Description: r.ModelName,
}
if r.CreatedAt != nil {
item.CreatedAt = r.CreatedAt.Format("Y-m-d H:i:s")
}
items = append(items, item)
}
return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// requireOpenTeamEnabled 校验租户已启用团队功能。
// 与 internal/logic/tenant 的 requireTeamEnabled 同义;那个是包内私有函数,
// 此处不便跨包复用(tenant 包体量大且反向依赖会形成环),故按同一判据本地实现。
func requireOpenTeamEnabled(ctx context.Context, tenantID int64) error {
enabled, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return err
}
if !enabled.Bool() {
return errOpen(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled)
}
return nil
}
// requireOpenMemberSeat 校验租户仍有可用席位,判据与控制台 InviteMember 完全一致:
// 活跃成员数 < 生效上限(上限为 0 表示不限)。
func requireOpenMemberSeat(ctx context.Context, tenantID int64) error {
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Count()
if err != nil {
return err
}
effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenantID)
if err != nil {
return gerror.Wrapf(err, "查询租户限制信息失败")
}
if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers {
return errOpen(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached)
}
return nil
}
// errOpen is a helper to create open platform errors with proper gerror codes.
func errOpen(code int, message string) error {
return gerror.NewCode(gcode.New(code, message, nil), message)
}
// ============================================================
// 项目管理
// ============================================================
func (s *sOpen) OpenProjectList(ctx context.Context, req *v1.OpenProjectListReq) (*v1.OpenProjectListRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.TntProjects.Ctx(ctx).Where("tenant_id", tenantID)
if req.Status != "" {
m = m.Where("status", req.Status)
}
if req.Keyword != "" {
kw := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ?", kw)
}
total, err := m.Count()
if err != nil {
return nil, err
}
type projectRow struct {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
Budget *float64 `json:"budget"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
var projects []projectRow
err = m.Fields("id, name, description, status, budget, created_at, updated_at").
OrderDesc("id").Page(page, pageSize).Scan(&projects)
if err != nil {
return nil, err
}
items := make([]v1.OpenProjectItem, 0, len(projects))
for _, p := range projects {
item := v1.OpenProjectItem{
ID: p.Id,
Name: p.Name,
Description: p.Description,
Status: p.Status,
}
if p.Budget != nil {
item.Budget = fmt.Sprintf("%.6f", *p.Budget)
} else {
item.Budget = "unlimited"
}
if p.CreatedAt != nil {
item.CreatedAt = p.CreatedAt.Format("Y-m-d H:i:s")
}
if p.UpdatedAt != nil {
item.UpdatedAt = p.UpdatedAt.Format("Y-m-d H:i:s")
}
items = append(items, item)
}
return &v1.OpenProjectListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *sOpen) OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreateReq) (*v1.OpenProjectCreateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
insertData := do.TntProjects{
TenantId: tenantID,
Name: req.Name,
Description: req.Description,
Status: "active",
}
if req.Budget > 0 {
budgetDecimal := billing.NewFromFloat(req.Budget)
insertData.Budget = &budgetDecimal
}
result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert()
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return &v1.OpenProjectCreateRes{ID: id}, nil
}
func (s *sOpen) OpenProjectGet(ctx context.Context, req *v1.OpenProjectGetReq) (*v1.OpenProjectGetRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
return nil, err
}
type projectRow struct {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
Budget *float64 `json:"budget"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
var p *projectRow
err := dao.TntProjects.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("id, name, description, status, budget, created_at, updated_at").
Scan(&p)
if err != nil {
return nil, err
}
if p == nil {
return nil, errOpen(consts.CodeNotFound, "项目不存在")
}
// Key 统计
type keyStatsRow struct {
Total int `json:"total"`
Active int `json:"active"`
}
var keyStats keyStatsRow
dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Fields("COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active").
Scan(&keyStats)
// 月度用量
type monthUsageRow struct {
TotalCost float64 `json:"total_cost"`
RequestCount int `json:"request_count"`
}
var monthUsage monthUsageRow
dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Where("created_at >= date_trunc('month', NOW())").
Fields("COALESCE(SUM(actual_cost), 0) as total_cost, COUNT(*) as request_count").
Scan(&monthUsage)
res := &v1.OpenProjectGetRes{
ID: p.Id,
Name: p.Name,
Description: p.Description,
Status: p.Status,
ActiveKeys: keyStats.Active,
TotalKeys: keyStats.Total,
MonthCost: fmt.Sprintf("%.6f", monthUsage.TotalCost),
MonthRequests: int64(monthUsage.RequestCount),
}
if p.Budget != nil {
res.Budget = fmt.Sprintf("%.6f", *p.Budget)
} else {
res.Budget = "unlimited"
}
if p.CreatedAt != nil {
res.CreatedAt = p.CreatedAt.Format("Y-m-d H:i:s")
}
if p.UpdatedAt != nil {
res.UpdatedAt = p.UpdatedAt.Format("Y-m-d H:i:s")
}
return res, nil
}
func (s *sOpen) OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdateReq) (*v1.OpenProjectUpdateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
if err != nil {
return nil, err
}
if project.Status == "archived" {
return nil, errOpen(consts.CodeBadRequest, "归档的项目不能直接编辑,请先取消归档")
}
data := do.TntProjects{}
hasUpdate := false
if req.Name != nil {
data.Name = *req.Name
hasUpdate = true
}
if req.Description != nil {
data.Description = *req.Description
hasUpdate = true
}
if req.Budget != nil {
if *req.Budget > 0 {
budgetDecimal := billing.NewFromFloat(*req.Budget)
data.Budget = &budgetDecimal
}
hasUpdate = true
}
// 预算耗尽状态的项目,更新预算后自动恢复为 active
if project.Status == "budget_exhausted" && req.Budget != nil && *req.Budget > 0 {
data.Status = "active"
hasUpdate = true
}
if !hasUpdate {
return nil, nil
}
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).Data(data).Update()
return nil, err
}
func (s *sOpen) OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchiveReq) (*v1.OpenProjectArchiveRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
if err != nil {
return nil, err
}
if project.Status == "archived" {
return nil, errOpen(consts.CodeBadRequest, "项目已归档")
}
keyPrefixes, err := getProjectApiKeyPrefixes(ctx, tenantID, req.Id)
if err != nil {
return nil, err
}
// 吊销项目下所有 active Key
_, err = dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Where("status", "active").
Data(do.ApiKeys{Status: "revoked"}).Update()
if err != nil {
return nil, err
}
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).
Data(do.TntProjects{Status: "archived"}).Update()
if err == nil {
invalidateApiKeyPrefixes(ctx, keyPrefixes)
}
return nil, err
}
func (s *sOpen) OpenProjectUnarchive(ctx context.Context, req *v1.OpenProjectUnarchiveReq) (*v1.OpenProjectUnarchiveRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
if err != nil {
return nil, err
}
if project.Status != "archived" {
return nil, errOpen(consts.CodeBadRequest, "只有归档状态的项目可以取消归档")
}
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).
Data(do.TntProjects{Status: "active"}).Update()
return nil, err
}
// ============================================================
// 项目 API Key 管理
// ============================================================
func (s *sOpen) OpenProjectKeyList(ctx context.Context, req *v1.OpenProjectKeyListReq) (*v1.OpenProjectKeyListRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
return nil, err
}
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id)
total, err := m.Count()
if err != nil {
return nil, err
}
type keyRow struct {
Id int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Status string `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
ExpiresAt *gtime.Time `json:"expires_at"`
}
var keys []keyRow
err = m.Fields("id, name, key_prefix, status, created_at, expires_at").
OrderDesc("id").Page(page, pageSize).Scan(&keys)
if err != nil {
return nil, err
}
items := make([]v1.OpenProjectKeyItem, 0, len(keys))
for _, k := range keys {
item := v1.OpenProjectKeyItem{
ID: k.Id,
Name: k.Name,
KeyPrefix: k.KeyPrefix,
Status: k.Status,
}
if k.CreatedAt != nil {
item.CreatedAt = k.CreatedAt.Format("Y-m-d H:i:s")
}
if k.ExpiresAt != nil {
item.ExpiresAt = k.ExpiresAt.Format("Y-m-d H:i:s")
}
items = append(items, item)
}
return &v1.OpenProjectKeyListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *sOpen) OpenProjectKeyCreate(ctx context.Context, req *v1.OpenProjectKeyCreateReq) (*v1.OpenProjectKeyCreateRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
if err != nil {
return nil, err
}
if project.Status != "active" {
return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用")
}
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
if err != nil {
return nil, err
}
insertData := do.ApiKeys{
TenantId: tenantID,
ProjectId: req.Id,
Name: req.Name,
EncryptedKey: encryptedKey,
KeyPrefix: prefix,
Scope: req.Scope,
Status: "active",
KeyType: "project",
}
if req.ExpiresAt != nil {
insertData.ExpiresAt = req.ExpiresAt
}
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
for _, modelName := range req.ModelNames {
if modelName != "" {
if _, err = dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
ApiKeyId: id,
ModelName: modelName,
}); err != nil {
g.Log().Warningf(ctx, "创建密钥模型范围失败: %v", err)
}
}
}
return &v1.OpenProjectKeyCreateRes{
ID: id,
Name: req.Name,
Key: rawKey,
KeyPrefix: prefix,
}, nil
}
func (s *sOpen) OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKeyDeleteReq) (*v1.OpenProjectKeyDeleteRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
return nil, err
}
// 验证密钥属于该项目和租户
var key *struct {
KeyPrefix string `json:"key_prefix"`
}
err := dao.ApiKeys.Ctx(ctx).
Where("id", req.KeyId).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Fields("key_prefix").
Scan(&key)
if err != nil {
return nil, err
}
if key == nil {
return nil, errOpen(consts.CodeNotFound, "密钥不存在")
}
_, err = dao.ApiKeys.Ctx(ctx).
Where("id", req.KeyId).
Where("tenant_id", tenantID).
Data(do.ApiKeys{Status: "revoked"}).Update()
if err == nil {
relay.InvalidateApiKey(ctx, key.KeyPrefix)
}
return nil, err
}
// ============================================================
// 项目用量查询
// ============================================================
func (s *sOpen) OpenProjectUsageStats(ctx context.Context, req *v1.OpenProjectUsageStatsReq) (*v1.OpenProjectUsageStatsRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
return nil, err
}
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
return nil, err
}
// 构建日期条件
useCustomRange := req.StartDate != "" && req.EndDate != ""
if useCustomRange {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
}
dateCondition := func(m *gdb.Model) *gdb.Model {
if useCustomRange {
return m.Where("created_at >= ?", req.StartDate+" 00:00:00").
Where("created_at <= ?", req.EndDate+" 23:59:59")
}
return m.Where("created_at >= NOW() - INTERVAL '30 days'")
}
// 汇总
type totalRow struct {
TotalCost float64 `json:"total_cost"`
RequestCount int `json:"request_count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
var totalStats totalRow
dateCondition(dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Fields("COALESCE(SUM(actual_cost), 0) as total_cost, COUNT(*) as request_count, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens")).
Scan(&totalStats)
// 每日趋势
type dailyRow struct {
Date string `json:"date"`
RequestCount int `json:"request_count"`
TotalCost float64 `json:"total_cost"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
var dailyStats []dailyRow
dateCondition(dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Fields("DATE(created_at) as date, COUNT(*) as request_count, COALESCE(SUM(actual_cost), 0) as total_cost, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens")).
Group("DATE(created_at)").OrderAsc("date").Scan(&dailyStats)
// 模型分布(Top 10)
type modelRow struct {
ModelName string `json:"model_name"`
RequestCount int `json:"request_count"`
TotalCost float64 `json:"total_cost"`
}
var modelStats []modelRow
dateCondition(dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id).
Fields("model_name, COUNT(*) as request_count, COALESCE(SUM(actual_cost), 0) as total_cost")).
Group("model_name").OrderDesc("total_cost").Limit(10).Scan(&modelStats)
daily := make([]v1.OpenProjectDailyStat, 0, len(dailyStats))
for _, d := range dailyStats {
daily = append(daily, v1.OpenProjectDailyStat{
Date: d.Date,
RequestCount: int64(d.RequestCount),
TotalCost: fmt.Sprintf("%.6f", d.TotalCost),
InputTokens: d.InputTokens,
OutputTokens: d.OutputTokens,
})
}
models := make([]v1.OpenProjectModelStat, 0, len(modelStats))
for _, m := range modelStats {
models = append(models, v1.OpenProjectModelStat{
ModelName: m.ModelName,
RequestCount: int64(m.RequestCount),
TotalCost: fmt.Sprintf("%.6f", m.TotalCost),
})
}
return &v1.OpenProjectUsageStatsRes{
TotalCost: fmt.Sprintf("%.6f", totalStats.TotalCost),
TotalRequests: int64(totalStats.RequestCount),
TotalInputTokens: totalStats.InputTokens,
TotalOutputTokens: totalStats.OutputTokens,
Daily: daily,
Models: models,
}, nil
}
func (s *sOpen) OpenProjectUsageLogs(ctx context.Context, req *v1.OpenProjectUsageLogsReq) (*v1.OpenProjectUsageLogsRes, error) {
tenantID := middleware.GetOpenTenantID(ctx)
if tenantID == 0 {
return nil, errOpen(consts.CodeUnauthorized, "未认证")
}
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
return nil, err
}
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
return nil, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", req.Id)
total, err := m.Count()
if err != nil {
return nil, err
}
type logRow struct {
Id int64 `json:"id"`
ModelName string `json:"model_name"`
RelayMode string `json:"relay_mode"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalCost float64 `json:"total_cost"`
LatencyMs int `json:"latency_ms"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
CreatedAt *gtime.Time `json:"created_at"`
}
var logs []logRow
err = m.Fields("id, model_name, relay_mode, input_tokens, output_tokens, actual_cost as total_cost, latency_ms, status, error_message, created_at").
OrderDesc("created_at").Page(page, pageSize).Scan(&logs)
if err != nil {
return nil, err
}
items := make([]v1.OpenProjectUsageLogItem, 0, len(logs))
for _, l := range logs {
item := v1.OpenProjectUsageLogItem{
ID: l.Id,
ModelName: l.ModelName,
RelayMode: l.RelayMode,
InputTokens: l.InputTokens,
OutputTokens: l.OutputTokens,
TotalCost: fmt.Sprintf("%.6f", l.TotalCost),
LatencyMs: l.LatencyMs,
Status: l.Status,
ErrorMessage: l.ErrorMessage,
}
if l.CreatedAt != nil {
item.CreatedAt = l.CreatedAt.Format("Y-m-d H:i:s")
}
items = append(items, item)
}
return &v1.OpenProjectUsageLogsRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// getProjectOrError 验证项目归属并返回基本信息
func (s *sOpen) getProjectOrError(ctx context.Context, projectID, tenantID int64) (*struct {
ID int64 `json:"id"`
Status string `json:"status"`
}, error) {
var project *struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
err := dao.TntProjects.Ctx(ctx).
Where("id", projectID).
Where("tenant_id", tenantID).
Scan(&project)
if err != nil {
return nil, err
}
if project == nil {
return nil, errOpen(consts.CodeNotFound, "项目不存在")
}
return project, nil
}