AI SaaS工程诊断实战(一)
点了‘确认订单’,请求返回200,页面还停在草稿。继续给AI一句‘再检查一下前端’,它可能加刷新、加等待,或者把两种字段都兼容。画面终于变了,却没有说清楚问题在哪里。
遇到这类问题,我会先选定一次操作,把入口、请求、响应、取值和页面放在同一条链上看。下面给一个能本地重做的最小例子。
本文为构造演示,与我的AI Coding系列共用教学素材。字段错误是人为设置的,不是某个模型或IDE的实测故障;不使用客户资料。文稿由AI辅助整理,示例与结论按文中方式核对。
1. 先观察这个不一致
服务里有两个独立对象,初始状态均为draft。两个页面使用相同处理逻辑,分别确认各自对象;关键差别是前端读取字段。
确认接口返回:
{"id":"broken","status":"confirmed"}
故障页面却这样取值:
const next = data.state;
if (next !== undefined) {
document.getElementById('display').textContent = next;
}
响应提供status,页面读取state,结果为undefined。条件分支没有更新文本,页面继续显示draft。服务端再读已经是confirmed,所以‘请求成功’‘服务状态改变’和‘界面显示正确’是这里需要分别检查的三件事。
2. 沿一次操作查六个位置
| 位置 | 检查动作 | 本例应观察到什么 |
|---|---|---|
| 入口 | 确认点了哪个对象 | 故障页操作对象为broken |
| 请求 | 看方法、路径与对象ID | POST /api/orders/broken/confirm |
| 服务结果 | 再次读取同一对象 | GET返回status=confirmed |
| 响应 | 对照实际层级、字段与值 | 顶层status,值confirmed |
| 消费 | 找前端实际读取位置 | 代码读取state,值undefined |
| 展示 | 读取页面最后状态 | 仍是draft |
本例的断点就在响应与前端消费之间。真实项目也可能是环境不一致、操作对象错误、缓存或异步任务尚未完成;这张表帮助定位,不把同一种表象都归为字段问题。
3. 依据约定决定改哪边
本例约定成功响应的顶层字段为status,所以将前端改为:
const next = data.status;
if (next !== undefined) {
document.getElementById('display').textContent = next;
}
这是一处最小修正。data.state ?? data.status在这里没有必要,因为示例不存在两个需要兼容的正式版本。如果真实服务有版本迁移,兼容可能合理,但要说明兼容谁、何时删除。
反过来,如果项目的权威约定是state,该调整的可能是服务端。先找实际约定,再决定改动位置。不要拿本文的字段名当成通用规范。
4. 修正之后要检查什么
在两个独立页面分别执行确认后,对照以下结果:
| 观察项 | /broken | /fixed |
|---|---|---|
| POST状态 | 200 | 200 |
| 返回status | confirmed | confirmed |
| 前端读取 | state→undefined | status→confirmed |
| 页面展示 | draft | confirmed |
| 再执行GET | confirmed | confirmed |
修正页只是证明本次操作中页面消费字段与响应一致。服务使用进程内字典,没有数据库持久化;重启会重置。登录、权限、并发、异步任务、缓存和错误提示均未作为本篇验证结论。
这里还留有一个刻意简化:缺字段时静默不更新。生产代码需要按实际契约校验结果并展示错误,不应把这段分支直接当成完整异常处理。
5. 在本地重做
把文末代码保存为demo.py,使用Python 3运行:
python demo.py
分别打开:
http://127.0.0.1:8767/broken
http://127.0.0.1:8767/fixed
点击‘确认订单’。页面会显示请求、HTTP状态、实际响应、取值字段、页面状态及一次GET再读。用完在终端停止服务。本例仅监听本机,用于教学;Python自带的简单HTTP服务不作为生产部署方案。
6. 下次可以直接填这张检查卡
本次操作/对象/环境:
期望结果/实际结果:
请求方法、路径、对象ID:
响应状态、层级、字段和类型:
独立读取结果及依据:
前端实际取值与写入位置:
页面最后状态:
断点、改动与验证结果:
尚未检查的环节:
先让AI按卡片列证据和未知,再提出修改。一次请求返回成功之后,继续看用户期待的结果有没有发生;把这条链说明白,修复范围通常也更容易确定。
—— Linksass|AI SaaS独立开发者
完整演示代码
代码仅使用Python标准库。两个页面和API都由同一服务提供。
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
orders = {"broken": "draft", "fixed": "draft"}
PAGE = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<title>前后端字段契约演示</title><style>body{font:18px system-ui;max-width:850px;margin:50px auto;line-height:1.8}pre{background:#eef2f6;padding:20px;white-space:pre-wrap}button{font:inherit;padding:8px 20px}</style>
<h1>前后端字段契约:MODE</h1>
<p>构造演示:同一响应,分别读取 state / status。服务只在本机运行,存储为进程内字典。</p>
<p>当前展示:<strong id="display">draft</strong></p>
<button id="confirm">确认订单</button><pre id="evidence">尚未操作</pre>
<script>
document.getElementById('confirm').onclick = async () => {
const response = await fetch('/api/orders/MODE/confirm', {method:'POST'});
const data = await response.json();
const next = data.FIELD;
if (next !== undefined) document.getElementById('display').textContent = next;
const rereadResponse = await fetch('/api/orders/MODE');
const reread = await rereadResponse.json();
document.getElementById('evidence').textContent = JSON.stringify({
request: 'POST /api/orders/MODE/confirm', http: response.status,
response: data, frontend_field: 'FIELD', frontend_value: next ?? '(undefined)',
displayed: document.getElementById('display').textContent,
reread_http: rereadResponse.status, reread: reread
}, null, 2);
};
</script></html>'''
class Handler(BaseHTTPRequestHandler):
def reply(self, payload, code=200, html=False):
content = payload.encode('utf-8') if html else json.dumps(payload).encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json')
self.send_header('Content-Length', str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_GET(self):
mode = self.path.strip('/')
if mode in orders:
self.reply(PAGE.replace('MODE', mode).replace('FIELD', 'state' if mode == 'broken' else 'status'), html=True)
elif self.path.startswith('/api/orders/') and self.path.rsplit('/', 1)[-1] in orders:
key = self.path.rsplit('/', 1)[-1]
self.reply({'id': key, 'status': orders[key]})
else:
self.reply({'error': 'not found'}, 404)
def do_POST(self):
parts = self.path.strip('/').split('/')
if len(parts) == 4 and parts[:2] == ['api', 'orders'] and parts[2] in orders and parts[3] == 'confirm':
key = parts[2]
orders[key] = 'confirmed'
self.reply({'id': key, 'status': orders[key]})
else:
self.reply({'error': 'not found'}, 404)
if __name__ == '__main__':
print('Demo: http://127.0.0.1:8767/broken and /fixed', flush=True)
HTTPServer(('127.0.0.1', 8767), Handler).serve_forever()