常见问题
按「现象 → 原因 → 解决」组织的高频故障速查表。每条都对照仓库源码核实过,看到相同现象直接照做。
启动与环境
后端和前端各自跑在哪个端口?
| 进程 | 端口 | 备注 |
|---|---|---|
| 后端 Development | 9708 | API 文档 http://127.0.0.1:9708/scalar |
| 后端 Production | 9709(launch profile;Docker 镜像内为 9708) | 实际以 Hosting:Urls 为准,也可由容器端口映射覆盖 |
| 前端 dev server | 9800 | VITE_PORT |
开发态前端不直连后端:VITE_API_BASE_URL 留空,请求打到自己的 dev server,再由 Vite 代理转发到 VITE_DEV_PROXY_TARGET(默认 http://localhost:9708)——同源转发,天然没有 CORS 问题。
改后端端口后前端连不上
改的是 Hosting:Urls 或 launch profile,别忘了同步改 frontend/.env.development 的 VITE_DEV_PROXY_TARGET。
前端报跨域(CORS)
生产部署前后端不同源时才会遇到。三条路,选一条:
- 前端与 API 同域,由 Nginx 反向代理
/api(推荐,仓库自带frontend/nginx.conf)。 - 配后端网关的
Gateway.AllowedOrigins放行前端源。 - 开发期保持走 Vite 代理,别把
VITE_API_BASE_URL填成后端绝对地址。
启动报 Redis WRONGPASS
仓库默认连接串用的是 ACL 用户 user=redis,password=redis,而官方 redis 镜像默认既无密码也没有名为 redis 的用户。按 开发环境 里的 docker run 命令建好同名 ACL 用户即可;本地图省事也可以把 XiHan:Caching:Redis:IsEnabled 设为 false 退化成进程内内存缓存(会失去分布式缓存/锁/队列)。
表没建好 / 首次启动就登录不了
全新数据库首次启动会自动建表 + 播种子数据。初始化中途失败时,可在确认没有业务数据后删库重试;存量数据库不要直接删除,应先核对 UpdateScripts 与实际迁移入口,见下文。
初始超管账号是什么?
| 字段 | 值 |
|---|---|
| 账号 | superadmin |
| 密码 | SuperAdmin@123 |
初始密码写在种子里,不走配置;账号标记为需要本人改密,参数「密码设置」(saas.auth.password)的 forceChange 开启后首次登录即要求修改。生产首次登录后立即修改。
演示数据怎么开关
种子分两类:基础数据(超管、权限目录、菜单、套餐、参数等,始终播种,是系统运行所需)与演示数据(覆盖各种情况的演示租户、组织、角色、账号)。Saas:Seed:EnableDemoData 为 true 才写演示数据,缺省或 false 都不写;仓库的开发环境配置开启、生产环境关闭。演示账号密码都是 Demo@123,清单见 框架简介:种子数据。
认证与令牌
怎么获取 token?
POST /api/Auth/Login,body 传 username(邮箱)+ password,成功后从 data.token.accessToken 取。完整流程(含 2FA 分支、验证码登录、刷新、切租户)与可直接照抄的 curl 见 接口对接指南 · 怎么获取 token。
登录一直提示账号或密码错误
username 字段要传邮箱——邮箱是全平台唯一登录标识;只有平台账号(TenantId=0,如超管)才可以用用户名登录。
接口一直 401,甚至陷入「刷新 → 401 → 刷新」
按这个顺序查:
- 请求头写全了吗:
Authorization: Bearer <accessToken>,Bearer后有一个空格。 - Access Token 默认 60 分钟就过期了,要用
POST /api/Auth/RefreshToken换新,必须同时提交旧accessToken与refreshToken,缺一即失败。 - Refresh Token 默认 7 天,过期只能重新登录。
- 会话被撤销(管理员踢下线、删除用户、超出最大设备数被顶)后,令牌即便没过期也不再有效。
前端 RequestClient 已内建自动刷新与并发排队,刷新请求自身带 _isRefresh 标记不会再次触发刷新,不存在死循环;如果你在裸调接口,自己实现时也要加这个标记。
接口返回 423,前端弹回了登录页
423 不是 401。它表示会话被锁屏——用户身份仍然有效,只是当前会话锁住了。正确处理是弹出解锁遮罩、调 POST /api/Auth/UnlockSession,不要清 token 跳登录页。
给用户加了权限,需要重新登录才生效吗?
不需要。权限码不冻结进 JWT(超管例外,只放一个通配 * 作快路径),鉴权走服务端授权快照实时判定,授予/回收立刻生效。
切换租户会不会把我原来的登录顶掉?
不会。切租户是复用当前会话轮换令牌:不发登录事件、不新增设备记录。调 POST /api/Auth/SwitchTenant,tenantId 传 null 即回到平台运维态。
权限与菜单
新加的菜单在前端不显示
菜单种子是 fail-closed 的:SaasMenuSeeder 先按 PermissionCode 去查 SysPermission,查不到直接报错、启动失败(错误里写明缺的是哪个权限码)。所以:
- 权限码得先在
SaasPermissionCodes里定义,并追加进All; - 权限定义要加进
SaasPermissionDefinitions.Groups(这才是落库的那份),权限目录阶段天然排在菜单阶段之前; PageRegistry里父目录要排在子项之前(种子按顺序解析ParentId);- 新增独立的平台模块时,照
SeedOrders的阶段与模块号段取Order:权限目录继承PermissionCatalogSeederBase,菜单继承PageRegistryMenuSeederBase,资源型权限用的操作来自 SaaS 统一播的操作字典;业务模块不写种子,实现IPermissionCatalogContribution/IMenuPageContribution交给 SaaS 的汇总种子(代码生成产出的就是这两个登记类)。
改完重建库或重跑种子。详见 二次开发 · 接线点检查清单。
菜单出来了,但点进去 404 / 白屏
后端 PageDescriptor.Component 决定前端视图落点:约定 Component = Path 去掉前导斜杠 + /index,对应 frontend/src/views/**/index.vue,由 import.meta.glob 匹配。
例外是 _core 页面(个人中心、仪表盘、关于页等,落在 packages/views/_core):Component 写 _core/xxx/index,必须同时在 packages/router/dynamic.ts 的 coreComponentMap 里登记,否则匹配不到组件、回退 not-found。
我有这个权限码,接口还是 403
依次排查:
- 租户版本(Edition)门控:租户所属版本的权限白名单没放行该权限码,运行时会被拦掉。
- 作用侧:该权限码不在当前上下文生效(平台侧权限进不了业务租户,反之亦然)。
- 模仿登录:模仿态下禁用清单里的权限码一律拒绝。
- 用户直授 Deny:直授的拒绝优先于任何角色授予。
- 权限委托已到期或被撤销。
数据范围不会导致 403:它目前只收窄用户列表,表现是「有权限但查不到数据」。约束规则也不参与请求期判定,只在分配角色等授权写入时拦截。
完整判定链见 权限模型。
列表少了几列 / 排序点了没反应
字段级安全(FLS)在服务端门控:读侧经 IFieldSecurityService.GuardQueryAsync 门控,只有能看明文的字段才允许参与排序、过滤与关键字搜索,其余被静默剔除;剔完没有有效排序时回退默认排序。前端 Schema 页也会按字段 permission 直接不渲染该列。所以是权限问题,不是 bug。
接口与联调
新加的分页方法收不到请求体
分页方法必须显式标 [HttpPost]。方法名以 Get 开头会被动态 API 约定推导成 GET,body 自然绑不上。
[PermissionAuthorize(SaasPermissionCodes.Position.Read)]
[HttpPost] // ← 别漏
public async Task<PageResultDtoBase<PositionListItemDto>> GetPositionPageAsync(
PositionPageQueryDto input, CancellationToken cancellationToken = default) { … }生成的路由和我预期的不一样
动态 API 按方法名推导路由,三条容易忘的规则:
- 动词前缀被剥离:
GetUserPageAsync→ 动作名UserPage(不是GetUserPage)。 - 无匹配动词前缀时默认 POST:
SwitchTenantAsync→POST /api/Auth/SwitchTenant。 - 路由段只由显式
[FromRoute]参数产生。普通参数一律走查询串或 body,不会变成/{id}路径段。想要/api/User/User/{id}必须给参数标[FromRoute]。
前缀匹配要求词边界(前缀后必须是大写字母或下划线),所以 AddressBook 不会被 Add 命中。完整推导表见 接口对接指南。
返回的 ID 变成了字符串
框架把 long 统一序列化为 JSON 字符串,避免 JavaScript Number 精度溢出(雪花 ID 会溢出 53 位安全整数)。前端类型标成 string,需要运算时显式 Number()。反序列化时数字与字符串都接受。
响应里某个字段整个不见了
服务端 DefaultIgnoreCondition = WhenWritingNull:值为 null 的字段直接不输出。客户端把字段按可选处理,别用「字段存在与否」判断成功——判定成功请读 isSuccess。
oAuthProviders 还是 oauthProviders?
oAuthProviders。camelCase 命名策略只把首字母变小写,OAuthProviders → oAuthProviders。同类还有 oAuthApps 等,抄字段名时留意。
时间显示差了几个小时
后端存储恒为 UTC,输出时按请求头 X-Timezone(IANA 标识,如 Asia/Shanghai)换算。不发这个头就按服务端默认时区输出。前端已在请求拦截器里自动带上(用户已选时区优先,否则跟随浏览器 Intl)。
枚举值到底发数字还是发字符串?
- 响应:枚举输出为成员名字符串(如
"Enabled")。唯一例外是ApiResponse.code,恒为 int。 - 请求:数字和成员名都接受。前端契约层统一发数字(
QueryOperator.Between=4000等)。
后端开发
运行期 DI 解析不到我的领域服务
领域服务必须手写注册。领域服务接口不带 IScopedDependency / IDomainService 之类的 DI 标记接口,框架的约定注册扫不到它,得在 Extensions/ServiceCollectionExtensions.cs 的 AddSaasDomainServices(或模块自己的对应方法)里显式登记:
services.AddScoped<IPositionDomainService, PositionDomainService>();对比之下:仓储(继承 SaasRepository → IScopedDependency)和应用服务 / 查询服务(实现 IApplicationService)都由约定自动注册,不用手写。这是纵切片最常见的漏接线点。
我覆盖了框架的默认实现,但跑起来还是默认的
框架模块用 TryAdd 先注册默认实现(IPermissionChecker、IUserStore、IJobStore、IAiProviderConfigStore、各 Bot *ConfigStore 等),你再 TryAdd 会被静默忽略。覆盖必须用 Replace:
services.Replace(ServiceDescriptor.Singleton<IAiProviderConfigStore, SaasAiProviderConfigStore>());事件处理器不触发
本地事件总线只自动发现「以接口为服务类型」的注册,裸 services.AddTransient<具体处理器>() 不会被订阅、静默失败。处理器必须显式加入 XiHanLocalEventBusOptions.Handlers——Saas 封装了 AddSaasLocalEventHandler<T>()(内部 AddTransient + Configure<XiHanLocalEventBusOptions>(o => o.Handlers.AddIfNotContains(typeof(T)))),在 AddSaasEventHandlers 里登记即可。
匿名 Minimal API 端点调应用服务时永久挂起
匿名端点没有工作单元中间件,而应用服务被 Castle 代理包着,拦截器会急切开事务从而死锁。解法是绕开代理:注入真正的依赖直连,或用 ProxyHelper.UnProxy 取出真实目标实例再调。仓库里的 OAuth 回调端点就是这么处理的(ExternalLoginAsync 标 [DynamicApi(IsEnabled = false)] 不对外暴露,只由回调端点经未代理实例调用)。
数据变更日志(Diff)一条都没有
需要两个条件同时成立:
- 配置
XiHan:Data:SqlSugarCore:EnableDiffLog设为true(默认是false,不开则 AOP 不挂载、差异被直接丢弃); - 写操作走仓储并显式调用了
.EnableDiffLogEvent(businessData)。
生产环境的 appsettings 常被 gitignore,容易漏配,记得在服务器上单独补。另外只有走仓储的写才会被覆盖到。
部署后接口报 42703 column does not exist
DbInitializer 表存在就跳过创建(日志里是「表已存在,跳过创建」),它从不为已有表补列。所以给既有实体加字段后部署必炸。
不要依赖 CodeFirst 给已有表补列。正式版本应在 WebHost/UpdateScripts/<版本>/<版本>.sql 中加入前向迁移,启动时升级引擎会按版本执行并记录到 SysMigrationHistory(XiHan:Upgrade:EnableAutoCheckOnStartup 关掉时不执行);临时环境也可重建库或手工 ALTER TABLE。
本地 dotnet build 报文件被占用
运行中的应用会锁住 DLL。停掉正在运行的实例再构建。后端由部署流程在服务器上构建/发布,本地改动需部署后才生效,线上诊断以加日志为主。
前端
页面整片白屏,控制台报 Invalid linked format
语言包文案里出现了裸 @(如 联系 @admin)。vue-i18n 会把 @ 当作 linked message 语法解析并抛错。必须转义成 {'@'}:
// ❌ 白屏
contact: '联系 @admin'
// ✅
contact: "联系 {'@'}admin"新增文案前先扫一遍裸 @。
图标显示成空白
图标走 Iconify 离线模式,运行期只保证 lucide / tabler / mdi / simple-icons 这四个已预加载的图标集能直接渲染。用了未预加载的集(carbon / ep / heroicons 等)会渲染为空——离线 Icon 对已挂载组件不会因后加载而重渲染。品牌图标优先用 simple-icons:* 或 tabler:brand-*。
上传的文件在线上打不开(404)
本地存储返回的是根相对路径 /uploads/...。前后端同源没问题,线上前后端不同源时必须拼上 VITE_API_BASE_URL 的 origin。仓库里的 toAbsoluteFileUrl / useAvatarUrl 已经处理了这件事,自己拼 URL 时别忘了。
跑 lint 把不相干的文件改坏了
pnpm run lint:fix 展开是 oxlint --fix && eslint . --fix,会扫全仓并改动与你无关的文件。只对本次改动的文件跑:
npx eslint src/views/identity/position/index.vue --fix误跑了就 git checkout HEAD -- <非本次任务的文件> 还原。
部署与运维
升级版本要写数据迁移吗?
要写前向 SQL 迁移。 首次部署由 CodeFirst 建库建表;已有数据库的变更放入 WebHost/UpdateScripts/<版本>/<版本>.sql。XiHan:Upgrade:EnableAutoCheckOnStartup 为 true(缺省值)时,应用启动会逐库执行待执行脚本、记台账,失败即中断启动;关掉它则要在发布流程里先完成迁移。当前脚本为 PostgreSQL 方言,切换数据库时需提供对应实现。详见升级与迁移。
启用 AI 知识库(RAG)前要准备什么?
需要先部署 Qdrant 向量库并配好 XiHan:AI:Rag 连接参数。全新数据库会自动创建 RAG 表并执行种子;存量库应随版本升级脚本迁移。见 AI 能力 与 开发环境 · Qdrant。
怎么定位一次线上请求?
每个响应都带 traceId(信封字段)与 X-Trace-Id 响应头。拿它去日志、审计日志、链路时间线里检索,就能串起这一次请求的访问 / API / 操作 / 异常 / 实体变更全部记录。启用 OpenTelemetry(XiHan:Observability,默认关闭)后该值就是 W3C 的 32-hex TraceId,可直接在 Jaeger / Tempo 里查。
还没解决?
- 接口对接指南:响应信封、业务码、请求头、分页协议的完整规范
- 二次开发:新增功能的完整接线清单(漏哪一步会怎样)
- 权限模型:403 / 查不到数据的判定链
- 框架常见问题:模块装配、DI、事务、拦截器层面的坑
- 仍未解决可到 GitHub Issues 提问,附上
traceId与复现步骤
