chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
package applogic
import (
"context"
"fmt"
"log"
"strings"
"aijianzhan/platform/internal/agentcap"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/meta"
"aijianzhan/platform/internal/smsstore"
"aijianzhan/platform/internal/svc"
"aijianzhan/platform/internal/types"
"aijianzhan/platform/internal/userstore"
)
type AuthLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AuthLogic {
return &AuthLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *AuthLogic) IssueToken(req *types.TokenReq) (*types.TokenResp, error) {
gt := strings.TrimSpace(strings.ToLower(req.GrantType))
if gt == "client_credentials" || (req.ClientID != "" && req.ClientSecret != "") {
return l.IssueClientCredentials(req.ClientID, req.ClientSecret)
}
want := l.svcCtx.Config.Auth.IssueSecret
if want == "" {
want = l.svcCtx.JWT.AccessSecret
}
if req.Secret == "" || req.Secret != want {
return nil, fmt.Errorf("invalid issue secret")
}
role := req.Role
if role == "" {
role = authx.Role管理员
}
return l.issue(req.TenantID, req.UserID, role, "", "", 0)
}
func (l *AuthLogic) Register(req *types.RegisterReq) (*types.TokenResp, error) {
if l.phoneLoginOnly() {
return nil, fmt.Errorf("本系统仅支持手机号登录,请联系管理员开通账号并绑定手机")
}
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
u, err := l.svcCtx.Users.Register(l.ctx, req.Username, req.Password, req.DisplayName)
if err != nil {
return nil, err
}
return l.issueUser(u)
}
func (l *AuthLogic) phoneLoginOnly() bool {
// 有部署期限控制时,不必强制手机号登录
if l.svcCtx.Config.License.Enabled {
return false
}
return l.svcCtx.Config.Auth.PhoneLoginOnly
}
// PhoneLoginOnlyPolicy 对外暴露是否强制仅手机号登录License 开启时为 false
func (l *AuthLogic) PhoneLoginOnlyPolicy() bool {
return l.phoneLoginOnly()
}
func (l *AuthLogic) Login(req *types.LoginReq) (*types.TokenResp, error) {
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
smsCode := strings.TrimSpace(req.SMSCode)
phone := strings.TrimSpace(req.Phone)
account := strings.TrimSpace(req.Username)
if account == "" {
account = phone
}
// 手机号 + 短信验证码
if smsCode != "" {
if phone == "" {
phone = account
}
ns, err := userstore.NormalizePhone(phone)
if err != nil {
return nil, err
}
if l.svcCtx.SMS == nil {
return nil, fmt.Errorf("短信服务未启用")
}
if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, ns, smsCode); err != nil {
return nil, err
}
u, err := l.svcCtx.Users.GetByPhone(l.ctx, ns)
if err != nil {
return nil, fmt.Errorf("该手机号未绑定账号")
}
if u.Status == "disabled" {
return nil, fmt.Errorf("账号已停用")
}
return l.issueUser(u)
}
if strings.TrimSpace(req.Password) == "" {
return nil, fmt.Errorf("请填写密码或短信验证码")
}
// 仅手机号登录:必须用手机号+密码
if l.phoneLoginOnly() {
loginPhone := phone
if loginPhone == "" {
loginPhone = account
}
ns, err := userstore.NormalizePhone(loginPhone)
if err != nil {
return nil, fmt.Errorf("请使用手机号登录")
}
u, err := l.svcCtx.Users.Login(l.ctx, ns, req.Password)
if err != nil {
return nil, err
}
if err := l.enforceLoginPolicy(u, false); err != nil {
return nil, err
}
return l.issueUser(u)
}
// 兼容模式:用户名/手机号 + 密码
if account == "" {
return nil, fmt.Errorf("请填写用户名或手机号")
}
u, err := l.svcCtx.Users.Login(l.ctx, account, req.Password)
if err != nil {
return nil, err
}
viaUsername := !userstore.LooksLikePhone(account)
if err := l.enforceLoginPolicy(u, viaUsername); err != nil {
return nil, err
}
return l.issueUser(u)
}
func (l *AuthLogic) enforceLoginPolicy(u *userstore.User, viaUsername bool) error {
cfg := l.svcCtx.Config.Auth
phoneOnly := l.phoneLoginOnly()
requirePhone := cfg.RequirePhoneBound || phoneOnly
if requirePhone && strings.TrimSpace(u.Phone) == "" {
return fmt.Errorf("本部署要求绑定手机号后才能登录,请联系管理员")
}
if !viaUsername {
return nil
}
if phoneOnly || cfg.DisableUsernameLoginIfPhoneBound || u.UsernameLoginDisabled {
if strings.TrimSpace(u.Phone) == "" {
return fmt.Errorf("已禁用用户名登录,请联系管理员绑定手机号")
}
return fmt.Errorf("已禁用用户名登录,请使用手机号+密码或短信验证码")
}
return nil
}
type SendSMSResult struct {
ExpiresIn int
RetryAfter int
DebugCode string
Message string
}
func (l *AuthLogic) SendLoginSMS(phone string) (*SendSMSResult, error) {
if l.svcCtx.Users == nil || l.svcCtx.SMS == nil {
return nil, fmt.Errorf("短信服务未启用")
}
provider := strings.ToLower(strings.TrimSpace(l.svcCtx.Config.SMS.Provider))
if provider == "off" || provider == "disabled" {
return nil, fmt.Errorf("短信登录未开启")
}
ns, err := userstore.NormalizePhone(phone)
if err != nil {
return nil, err
}
if _, err := l.svcCtx.Users.GetByPhone(l.ctx, ns); err != nil {
return nil, fmt.Errorf("该手机号未绑定账号,请联系管理员绑定手机后再登录")
}
code, expiresIn, retryAfter, err := l.svcCtx.SMS.Issue(smsstore.PurposeLogin, ns)
if err != nil {
return &SendSMSResult{RetryAfter: retryAfter}, err
}
out := &SendSMSResult{
ExpiresIn: expiresIn,
Message: "验证码已发送",
}
if provider == "" || provider == "dev" {
log.Printf("[sms:dev] login code for %s = %s (expires %ds)", ns, code, expiresIn)
out.DebugCode = code
out.Message = "开发模式:验证码已写入服务日志(并返回 debug_code"
}
return out, nil
}
func (l *AuthLogic) ChangePassword(oldPassword, newPassword string) error {
if l.svcCtx.Users == nil {
return fmt.Errorf("user store unavailable")
}
uid := authx.UserID(l.ctx)
if uid <= 0 {
return fmt.Errorf("未登录")
}
return l.svcCtx.Users.ChangePassword(l.ctx, uid, oldPassword, newPassword)
}
func (l *AuthLogic) BindPhone(phone string) (*userstore.User, error) {
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
uid := authx.UserID(l.ctx)
if uid <= 0 {
return nil, fmt.Errorf("未登录")
}
if l.phoneLoginOnly() && strings.TrimSpace(phone) == "" {
return nil, fmt.Errorf("本部署仅支持手机号登录,不可解绑手机号")
}
return l.svcCtx.Users.BindPhone(l.ctx, uid, phone)
}
func (l *AuthLogic) SetUsernameLoginDisabled(disabled bool) (*userstore.User, error) {
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
uid := authx.UserID(l.ctx)
if uid <= 0 {
return nil, fmt.Errorf("未登录")
}
return l.svcCtx.Users.SetUsernameLoginDisabled(l.ctx, uid, disabled)
}
func (l *AuthLogic) Me() (*userstore.User, error) {
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
uid := authx.UserID(l.ctx)
if uid <= 0 {
return nil, fmt.Errorf("未登录")
}
return l.svcCtx.Users.GetByID(l.ctx, uid)
}
func (l *AuthLogic) issue(tenantID, userID int64, role, username, displayName string, orgUnitID int64) (*types.TokenResp, error) {
role = authx.NormalizeRole(role)
token, exp, err := authx.IssueToken(l.svcCtx.JWT, tenantID, userID, role, orgUnitID)
if err != nil {
return nil, err
}
secret := l.svcCtx.Config.Agent.CapsuleSecret
if secret == "" {
secret = l.svcCtx.JWT.AccessSecret
}
return &types.TokenResp{
AccessToken: token,
TokenType: "Bearer",
ExpiresAt: exp,
TenantID: tenantID,
UserID: userID,
Username: username,
DisplayName: displayName,
Role: role,
OrgUnitID: orgUnitID,
AgentKey: agentcap.PublicAgentKey(secret, tenantID, userID),
}, nil
}
type CapsuleLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCapsuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CapsuleLogic {
return &CapsuleLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
tenantID := authx.TenantID(l.ctx)
userID := authx.UserID(l.ctx)
if authx.Role(l.ctx) == authx.RoleAgent {
if l.svcCtx.Agents == nil {
return nil, fmt.Errorf("agent store unavailable")
}
ok, err := l.svcCtx.Agents.HasAppAccess(l.ctx, authx.AgentID(l.ctx), slug)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("app not granted to agent")
}
}
app, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
if err != nil {
return nil, err
}
if app.Status != meta.StatusPublished {
return nil, fmt.Errorf("app not published")
}
base := l.svcCtx.Config.PublicBaseURL
if base == "" {
base = fmt.Sprintf("http://127.0.0.1:%d", l.svcCtx.Config.Port)
}
desc := &agentcap.Descriptor{
Version: "1",
BaseURL: strings.TrimRight(base, "/"),
AppSlug: slug,
TenantHint: fmt.Sprintf("t%d", tenantID),
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
}
for _, r := range app.Blueprint.Apis.Resources {
path := r.Path
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
full := fmt.Sprintf("/api/v1/apps/%s%s", slug, path)
methods := make([]string, 0, len(r.Operations))
for _, op := range r.Operations {
switch op {
case "list", "get", "export":
methods = append(methods, "GET")
case "create", "import":
methods = append(methods, "POST")
case "update":
methods = append(methods, "PUT")
case "delete":
methods = append(methods, "DELETE")
}
}
var entityFields []agentcap.FieldSpec
pk := "id"
for _, e := range app.Blueprint.Entities {
if e.Name != r.Entity {
continue
}
pk = e.PrimaryKey
for _, f := range e.Fields {
entityFields = append(entityFields, agentcap.FieldSpec{Name: f.Name, Type: f.Type})
}
}
filters, sorts := []string{}, []string{}
if r.List != nil {
filters = r.List.AllowedFilters
sorts = r.List.AllowedSorts
}
name := strings.TrimPrefix(path, "/")
desc.Resources = append(desc.Resources, agentcap.ResourceSpec{
Name: name,
Path: full,
Methods: uniqStrings(methods),
Filters: filters,
Sorts: sorts,
Fields: entityFields,
PrimaryKey: pk,
})
}
secret := l.svcCtx.Config.Agent.CapsuleSecret
if secret == "" {
secret = l.svcCtx.JWT.AccessSecret
}
key := agentcap.DeriveKey(secret, tenantID, userID)
capsule, err := agentcap.Encrypt(key, desc)
if err != nil {
return nil, err
}
return &types.CapsuleResp{
Capsule: capsule,
Format: agentcap.Prefix,
Hint: "仅智能体使用 agent_key 解密;前端只展示密文",
}, nil
}
func uniqStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}