用 AWS SAM 做一个网站留言接口:
POST /feedback接收 JSON,校验后写入 DynamoDB。HTTP API 负责跨域和限流,Lambda 只有写这一张表的权限,日志保留 14 天。
先看清成品
本机执行结果:
app.py用 moto 模拟 DynamoDB 跑了 4 个测试全部通过;template.yaml通过 cfn-lint 1.58.0、sam validate --lint(SAM CLI 1.169.0)和sam build。没有sam deploy到真实账号,也没有运行sam local invoke(需要 Docker 和可访问的 DynamoDB)。
注意:AWS 按用量计费,价格以 AWS 官方价格页和账单为准,本文不列具体金额;做完实验记得删除资源。不要共享 root 账号或访问密钥,不要把密钥提交到仓库。中国大陆的 AWS 中国区(amazonaws.cn)是独立运营的分区,账号不通用,部分服务和 ARN 前缀(arn:aws-cn)不同,本文按全球区域编写。
隐私提示:留言里可能有邮箱等个人信息。上线前写清隐私说明,只收必要字段,并定期清理旧数据。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| HTTP API | https://xxx.execute-api.<区域>.amazonaws.com/feedback |
| Lambda | Python 3.13,arm64,128 MB,5 秒超时 |
| DynamoDB 表 | 按需计费 |
| 安全设置 | CORS 限定来源、限流、最小权限、蜜罐字段 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| AWS 账号 | 有 CloudFormation、Lambda、DynamoDB、API Gateway 权限 | 不用 root |
| SAM CLI | 本文用 1.169.0 | 按官方安装文档 |
| Python | 3.13(与运行时一致) | sam build 用本机 Python 构建 |
| 测试依赖 | pip install pytest boto3 moto |
本地测试 |
文件结构
20-aws-B-src/
├── template.yaml
├── src/
│ ├── app.py
│ └── requirements.txt
├── events/post.json
└── tests/test_app.py
第 1 步:SAM 模板
template.yaml:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: 留言反馈接口(HTTP API + Lambda + DynamoDB)
Parameters:
AllowedOrigin:
Type: String
Default: "https://example.com"
Description: 允许跨域调用的网站地址
Globals:
Function:
Runtime: python3.13
Timeout: 5
MemorySize: 128
Architectures: [arm64]
Resources:
FeedbackTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
FeedbackApi:
Type: AWS::Serverless::HttpApi
Properties:
CorsConfiguration:
AllowOrigins: [!Ref AllowedOrigin]
AllowMethods: [POST]
AllowHeaders: [content-type]
DefaultRouteSettings:
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 5
FeedbackFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: app.handler
Environment:
Variables:
TABLE_NAME: !Ref FeedbackTable
Policies:
- DynamoDBWritePolicy:
TableName: !Ref FeedbackTable
Events:
Post:
Type: HttpApi
Properties:
ApiId: !Ref FeedbackApi
Path: /feedback
Method: POST
FeedbackLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/lambda/${FeedbackFunction}
RetentionInDays: 14
Outputs:
FeedbackUrl:
Value: !Sub https://${FeedbackApi}.execute-api.${AWS::Region}.amazonaws.com/feedback
要点:
DynamoDBWritePolicy是 SAM 内置策略模板,只授予这张表的写操作。ThrottlingBurstLimit/ThrottlingRateLimit限制突发和每秒请求数,防止被刷。- 显式创建日志组并设保留期,否则 Lambda 日志默认永久保存。
第 2 步:函数代码
src/app.py:
import json, os, re, time, uuid
import boto3
_table = None
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def table():
global _table
if _table is None:
_table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
return _table
def reply(code, body):
return {"statusCode": code, "headers": {"content-type": "application/json; charset=utf-8"},
"body": json.dumps(body, ensure_ascii=False)}
def validate(data):
errors = []
msg = str(data.get("message", "")).strip()
if not 1 <= len(msg) <= 1000: errors.append("message 长度应为 1-1000")
email = str(data.get("email", "")).strip()
if email and not EMAIL.match(email): errors.append("email 格式不对")
if data.get("website"): errors.append("spam") # 蜜罐字段,正常用户看不到
return msg, email, errors
def handler(event, context):
try:
data = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return reply(400, {"error": "请求体不是 JSON"})
msg, email, errors = validate(data)
if errors:
return reply(400, {"error": ";".join(errors)})
item = {"id": str(uuid.uuid4()), "message": msg, "created_at": int(time.time())}
if email: item["email"] = email
table().put_item(Item=item)
return reply(201, {"id": item["id"]})
website 是蜜罐字段:前端用 CSS 隐藏,正常用户不会填,机器人常会填。
第 3 步:本地测试
tests/test_app.py:
import json, os, sys
import boto3, pytest
from moto import mock_aws
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import app
@pytest.fixture
def ddb(monkeypatch):
monkeypatch.setenv("AWS_DEFAULT_REGION", "ap-northeast-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing"); monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("TABLE_NAME", "fb")
with mock_aws():
t = boto3.resource("dynamodb").create_table(TableName="fb", BillingMode="PAY_PER_REQUEST",
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}])
app._table = None
yield t
def call(body): return app.handler({"body": body}, None)
def test_ok(ddb):
ev = json.load(open(os.path.join(os.path.dirname(__file__), "..", "events", "post.json")))
r = app.handler(ev, None); assert r["statusCode"] == 201
item = ddb.get_item(Key={"id": json.loads(r["body"])["id"]})["Item"]
assert item["message"] == "希望周末延长营业"
def test_bad_json(ddb): assert call("{oops")["statusCode"] == 400
def test_empty_and_email(ddb):
r = call(json.dumps({"message": "", "email": "x"})); assert r["statusCode"] == 400
assert "message" in r["body"] and "email" in r["body"]
def test_honeypot(ddb): assert call(json.dumps({"message": "hi", "website": "x"}))["statusCode"] == 400
pip install pytest boto3 moto
python -m pytest -q tests
检查结果:4 passed。
events/post.json 是 HTTP API 2.0 格式的样例事件:
{"version": "2.0", "routeKey": "POST /feedback", "rawPath": "/feedback", "headers": {"content-type": "application/json"}, "body": "{\"message\": \"希望周末延长营业\", \"email\": \"a@example.com\"}", "isBase64Encoded": false}
第 4 步:校验与构建
sam validate --lint
sam build
检查结果(本机实际输出节选;sam build 成功后会列出后续可用命令):
template.yaml is a valid SAM Template
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
第 5 步:部署(本文未执行)
sam deploy --guided --parameter-overrides AllowedOrigin=https://你的网站
向导会问栈名、区域、是否允许创建 IAM 角色等,答完会写入 samconfig.toml,以后直接 sam deploy。
部署后测试:
URL=$(aws cloudformation describe-stacks --stack-name 你的栈名 \
--query "Stacks[0].Outputs[?OutputKey=='FeedbackUrl'].OutputValue" --output text)
curl -s -X POST "$URL" -H 'content-type: application/json' -d '{"message":"测试留言"}'
预期结果:返回 {"id": "..."},状态码 201;DynamoDB 控制台能看到这条记录。
第 6 步:前端调用
<form id="fb">
<textarea name="message" maxlength="1000" required></textarea>
<input name="email" type="email" placeholder="邮箱(选填)">
<input name="website" style="display:none" tabindex="-1" autocomplete="off">
<button>提交</button>
</form>
<script>
document.getElementById("fb").addEventListener("submit", async e => {
e.preventDefault();
const r = await fetch("你的 FeedbackUrl", {
method: "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify(Object.fromEntries(new FormData(e.target)))
});
alert(r.ok ? "已收到,谢谢" : (await r.json()).error);
});
</script>
调试示例
提交 {"message": "", "email": "x"},接口返回 400:
{"error": "message 长度应为 1-1000;email 格式不对"}
测试 test_empty_and_email 验证了两个错误会同时返回,用户一次就能改完。
出错了怎么办?
| 现象 | 原因 | 解决 |
|---|---|---|
| 浏览器报 CORS 错误 | AllowedOrigin 与网站地址不一致(含协议和端口) |
部署时传正确的地址 |
| 500 + 日志里 AccessDeniedException | 函数权限不足 | 检查 Policies 里的表名 |
sam build 报找不到 python3.13 |
本机 Python 版本不同 | 安装 3.13,或用 sam build --use-container |
| 部署时报日志组已存在 | 函数先被调用过,Lambda 自动建了日志组 | 删掉旧日志组再部署 |
| 429 Too Many Requests | 触发限流 | 正常现象;按需调整限流参数 |
清理
sam delete --stack-name 你的栈名
模板规则
- 权限用 SAM 策略模板,不写
*。 - 每个函数都设日志保留期。
- 输入校验写在函数里并有测试,不依赖前端。
项目资源
这个接口很小,但无服务器应用的几个关键点都在:模板即基础设施、最小权限、限流、日志保留期,以及不依赖云端就能跑的测试。