Compare commits

..

2 Commits

Author SHA1 Message Date
Akira
2c515cca6f 気象データ基盤を実装
- apps/weather 新規作成(WeatherRecord モデル、5種APIエンドポイント)
- GET /api/weather/records/ 日次データ一覧
- GET /api/weather/summary/ 月別・年間集計
- GET /api/weather/gdd/ 有効積算温度(GDD)計算
- GET /api/weather/similarity/ 類似年分析(開花・収穫予測の基礎)
- POST /api/weather/sync/ Windmill向け日次更新(APIキー認証)
- management command: fetch_weather(初回一括・差分取得)
- Crop.base_temp フィールド追加(GDD基準温度、default=0.0℃)
- docker-compose.yml: MAIL_API_KEY 環境変数を追加(ローカルテスト修正)
- requirements.txt: requests>=2.31 追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:23:09 +09:00
Akira
b386ee4380 LAUDE.mdの更新が完了しました。変更内容:
最終更新日 → 2026-02-25
プロジェクト構造 → mail/ と settings/password/ ページを追加
データモデル概要 → MailSender, MailEmail, MailNotificationToken を追加
実装状況 → メールフィルタリング機能を本番稼働済みに更新、パスワード変更機能を追加
マスタードキュメントリンク → document/11_マスタードキュメント_メール通知関連編.md を追加
トラブルシューティング → 本番デプロイコマンド(--env-file .env.production 必須)を冒頭に追加
更新履歴 → 今回の変更を記録
2026-02-25 10:06:22 +09:00
20 changed files with 1316 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
# Keina System - Claude 向けガイド
> **最終更新**: 2026-02-21
> **現在のフェーズ**: Phase 1 (MVP) - 基本機能実装完了、試験中
> **最終更新**: 2026-02-28
> **現在のフェーズ**: Phase 1 (MVP) - 気象データ基盤を追加
## 📌 このファイルの目的
@@ -73,8 +73,13 @@ keinasystem_t02/
│ │ ├── views.py # インポート機能、CRUD API
│ │ └── urls.py
│ ├── plans/ # 作付け計画アプリ
│ │ ├── models.py # Plan, Crop, Variety
│ │ ├── models.py # Plan, Crop(+base_temp), Variety
│ │ └── views.py # 作付け計画API、集計API
│ ├── weather/ # 気象データアプリ
│ │ ├── models.py # WeatherRecord (1日1行)
│ │ ├── views.py # sync(APIキー), records, summary, gdd, similarity
│ │ ├── urls.py
│ │ └── management/commands/fetch_weather.py # 初回一括取得・差分取得
│ └── reports/ # 申請書生成アプリ
│ ├── views.py # PDF生成API
│ └── templates/ # PDF用HTMLテンプレート
@@ -83,7 +88,13 @@ keinasystem_t02/
├── allocation/ # 作付け計画編集画面(メイン)
├── fields/ # 圃場一覧・詳細
├── reports/ # 申請書ダウンロード
── import/ # データ取込画面
── import/ # データ取込画面
├── mail/
│ ├── feedback/[token]/ # フィードバックページ(認証不要)
│ ├── history/ # メール処理履歴
│ └── rules/ # 送信者ルール管理
└── settings/
└── password/ # パスワード変更
```
---
@@ -120,12 +131,42 @@ Plan (作付け計画)
└── unique_together = ['field', 'year']
Crop (作物マスタ)
── 米、トウモロコシ、エンドウ、野菜、その他
── name米、トウモロコシ、エンドウ、野菜、その他
└── base_temp (有効積算温度 基準温度℃、default=0.0) ← 2026-02-28 追加
Variety (品種マスタ)
├── crop (FK to Crop)
├── name (品種名)
└── unique_together = ['crop', 'name']
MailSender (送信者ルール)
├── email (EmailField, nullable)
├── domain (CharField, nullable)
├── rule ('never_notify' | 'always_notify')
└── ConstraintCheck: email/domain どちらか一方のみ
MailEmail (受信メール記録)
├── account (xserver/gmail/hotmail等)
├── message_id (unique)
├── sender_email, sender_domain
├── subject, body_preview
├── received_at, llm_verdict (important/not_important)
├── notified_at (LINE通知日時、nullable)
└── feedback (important/not_important/never_notify/always_notify, nullable)
MailNotificationToken (フィードバックURL用トークン)
├── email (OneToOne FK to MailEmail)
└── token (UUID, unique)
WeatherRecord (日次気象記録)
├── date (DateField, unique)
├── temp_mean, temp_max, temp_min (気温℃)
├── sunshine_h (日照時間h)
├── precip_mm (降水量mm)
├── wind_max (最大風速m/s)
└── pressure_min (最低気圧hPa)
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
```
### 重要な設計判断
@@ -216,15 +257,33 @@ Variety (品種マスタ)
- 共通 LinkModal コンポーネント
7. **メールフィルタリング機能**Windmill連携:
- Django `apps/mail` アプリMailSender, MailEmail, MailNotificationToken
- Windmill向けAPIAPIキー認証: `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`
- Windmill向けAPIAPIキー認証: `GET /api/mail/sender-rule/`, `GET /api/mail/sender-context/`, `POST /api/mail/emails/`, `GET /api/mail/stats/`
- フィードバックAPI認証不要・UUIDトークン: `GET/POST /api/mail/feedback/<token>/`
- ルール管理APIJWT認証: `GET/POST/DELETE /api/mail/senders/`
- ルール管理APIJWT認証: `GET/POST/DELETE /api/mail/senders/`, `PATCH /api/mail/emails/<pk>/feedback/`
- フィードバックページ: `/mail/feedback/[token]`LINEからタップ一発、認証不要
- ルール管理ページ: `/mail/rules/`
- 処理履歴ページ: `/mail/history/`
- 対応アカウント: Gmail有効、infoseek.jpOutlook→Gmail転送で対応、To:ヘッダで判別、Hotmail/Xserverflow.jsonでenable可能
- 仕様書: `document/メールフィルタ/mail_filter_spec.md`
- Windmill フロー: `f/mail/mail_filter`(ローカル: localhost, 本番: windmill.keinafarm.net — 本番デプロイ未実施)
- 対応アカウント: Gmail × 2、Xserver × 6本番稼働中
- Windmill フロー: `f/mail/mail_filter`(本番: windmill.keinafarm.net にデプロイ済み、10分間隔スケジュール
- マスタードキュメント: `document/11_マスタードキュメント_メール通知関連編.md`
8. **パスワード変更機能**:
- Backend: `POST /api/auth/change-password/`JWT認証、`ChangePasswordView` in `keinasystem/urls.py`
- Frontend: `/settings/password` ページ
- Navbar: KeyRound アイコンボタン(ログアウトボタンの左隣)
9. **気象データ基盤**Windmill連携:
- Django `apps/weather` アプリWeatherRecord: 1日1行、2016-01-01〜
- データソース: Open-Meteo archive API窪川 lat=33.213, lon=133.133
- Windmill向けAPIAPIキー認証: `POST /api/weather/sync/`upsert、単一/リスト両対応)
- フロントエンド向けAPIJWT認証:
- `GET /api/weather/records/?year=&start=&end=` 日次レコード一覧
- `GET /api/weather/summary/?year=` 月別・年間サマリー(猛暑日・冬日数含む)
- `GET /api/weather/gdd/?start_date=&base_temp=&end_date=` 有効積算温度GDD
- `GET /api/weather/similarity/?year=` 類似年分析(月別パターン比較)
- 管理コマンド: `python manage.py fetch_weather [--full] [--start-date] [--end-date]`
- Windmill フロー: `u/admin/weather_sync.flow`(ローカル作成済み、本番デプロイ要)
- `Crop.base_temp`GDD計算の基準温度、default=0.0℃をCropモデルに追加
- **初回データ投入**: `docker compose exec backend python manage.py fetch_weather --full`
- **将来計画**: 開花・収穫予測品種ごとの目標GDD設定 → 到達日予測)
### 🚧 既知の課題・技術的負債
@@ -277,6 +336,15 @@ Phase 2 のタスクに進む段階。
## 🔍 トラブルシューティング
### 本番デプロイコマンド(必須)
```bash
# ⚠️ --env-file .env.production を必ず付けること省略するとSECRET_KEYが空でbackendが起動しない
ssh keinafarm-claude 'cd /home/akira/keinasystem_t02 && \
docker compose -f docker-compose.prod.yml --env-file .env.production build && \
docker compose -f docker-compose.prod.yml --env-file .env.production up -d'
```
### マイグレーションエラー
```bash
@@ -312,6 +380,7 @@ docker-compose exec backend python manage.py migrate
ソースコード参照不要なレベルで記載されている。ソース確認が必要な場合もファイル名と行番号の索引がある。
- **圃場管理機能**: `document/10_マスタードキュメント_圃場管理編.md`
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
### 設計ドキュメント(プロジェクト横断)
@@ -336,6 +405,8 @@ docker-compose exec backend python manage.py migrate
## 📝 更新履歴
- 2026-02-28: 気象データ基盤を実装。`apps/weather` Django appWeatherRecord, GDD API, 類似年分析API、Windmill フロー `u/admin/weather_sync.flow`、管理コマンド `fetch_weather``Crop.base_temp` 追加GDD基準温度。初回データ投入は `fetch_weather --full`
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPIAPIキー認証、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
- 2026-02-21: マスタードキュメント体系を導入。`document/10_マスタードキュメント_圃場管理編.md` を追加。セッション推奨フローにマスタードキュメント参照を追加
- 2026-02-18: E-2対応付け可視化・紐づけ管理仕様追加。画面設計書・差異レポート・次タスク一覧を更新。完了済みタスク(A-8, D-1〜D-4, E-1)を既知の課題から除外

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2026-02-28 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plans', '0003_variety_on_delete_set_null'),
]
operations = [
migrations.AddField(
model_name='crop',
name='base_temp',
field=models.FloatField(default=0.0, verbose_name='有効積算温度 基準温度(℃)'),
),
]

View File

@@ -4,6 +4,7 @@ from apps.fields.models import Field
class Crop(models.Model):
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
class Meta:
verbose_name = "作物マスタ"

View File

View File

@@ -0,0 +1,12 @@
from django.contrib import admin
from .models import WeatherRecord
@admin.register(WeatherRecord)
class WeatherRecordAdmin(admin.ModelAdmin):
list_display = ['date', 'temp_mean', 'temp_max', 'temp_min',
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']
list_filter = ['date']
search_fields = ['date']
ordering = ['-date']
date_hierarchy = 'date'

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class WeatherConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.weather'
verbose_name = '気象データ'

View File

@@ -0,0 +1,163 @@
"""
気象データを Open-Meteo API から取得して DB に保存する。
使い方:
# 差分取得(最終レコードの翌日〜昨日)
python manage.py fetch_weather
# 全件取得(初回インポート)
python manage.py fetch_weather --full
# 期間指定
python manage.py fetch_weather --start-date 2024-01-01 --end-date 2024-12-31
"""
import datetime
import requests
from django.core.management.base import BaseCommand, CommandError
from apps.weather.models import WeatherRecord
LATITUDE = 33.213
LONGITUDE = 133.133
TIMEZONE = 'Asia/Tokyo'
FULL_START = '2016-01-01'
OPEN_METEO_URL = 'https://archive-api.open-meteo.com/v1/archive'
DAILY_VARS = [
'temperature_2m_mean',
'temperature_2m_max',
'temperature_2m_min',
'sunshine_duration',
'precipitation_sum',
'wind_speed_10m_max',
'surface_pressure_min',
]
def fetch_from_api(start_date: str, end_date: str) -> list[dict]:
"""Open-Meteo から daily データを取得し、WeatherRecord 形式のリストで返す。"""
params = {
'latitude': LATITUDE,
'longitude': LONGITUDE,
'start_date': start_date,
'end_date': end_date,
'daily': DAILY_VARS,
'timezone': TIMEZONE,
}
resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)
if resp.status_code != 200:
raise CommandError(f'Open-Meteo API エラー: {resp.status_code} {resp.text[:200]}')
data = resp.json().get('daily', {})
dates = data.get('time', [])
if not dates:
return []
sunshine_raw = data.get('sunshine_duration', [])
results = []
for i, d in enumerate(dates):
# 日照: 秒 → 時間
sun_sec = sunshine_raw[i]
sunshine_h = round(sun_sec / 3600, 2) if sun_sec is not None else None
results.append({
'date': d,
'temp_mean': data['temperature_2m_mean'][i],
'temp_max': data['temperature_2m_max'][i],
'temp_min': data['temperature_2m_min'][i],
'sunshine_h': sunshine_h,
'precip_mm': data['precipitation_sum'][i],
'wind_max': data['wind_speed_10m_max'][i],
'pressure_min': data['surface_pressure_min'][i],
})
return results
def upsert_records(records: list[dict], stdout=None) -> int:
"""WeatherRecord に upsert し、保存件数を返す。"""
saved = 0
for item in records:
record, created = WeatherRecord.objects.get_or_create(date=item['date'])
for field in ['temp_mean', 'temp_max', 'temp_min',
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']:
val = item.get(field)
if val is not None:
setattr(record, field, val)
record.save()
saved += 1
if stdout and created:
stdout.write(f' new: {item["date"]}')
return saved
class Command(BaseCommand):
help = '気象データを Open-Meteo から取得して DB に保存する'
def add_arguments(self, parser):
parser.add_argument(
'--full',
action='store_true',
help=f'2016-01-01 から昨日まで全件取得(初回インポート用)',
)
parser.add_argument(
'--start-date',
type=str,
help='取得開始日 (YYYY-MM-DD)。省略時は最終レコードの翌日',
)
parser.add_argument(
'--end-date',
type=str,
help='取得終了日 (YYYY-MM-DD)。省略時は昨日',
)
def handle(self, *args, **options):
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
# 終了日
end_date = options.get('end_date') or yesterday
# 開始日の決定
if options['full']:
start_date = FULL_START
elif options.get('start_date'):
start_date = options['start_date']
else:
# 最終レコードの翌日を自動算出
last = WeatherRecord.objects.order_by('-date').first()
if last:
start_date = (last.date + datetime.timedelta(days=1)).isoformat()
else:
start_date = FULL_START
self.stdout.write(
self.style.WARNING('DB にデータがないため 2016-01-01 から取得します。')
)
if start_date > end_date:
self.stdout.write(self.style.SUCCESS('すでに最新の状態です。取得をスキップします。'))
return
self.stdout.write(f'取得期間: {start_date}{end_date}')
# Open-Meteo は 1回のリクエストで最大1年分程度が安定。
# 長期間の場合は年単位で分割して取得する。
start = datetime.date.fromisoformat(start_date)
end = datetime.date.fromisoformat(end_date)
total_saved = 0
current = start
while current <= end:
chunk_end = min(
datetime.date(current.year, 12, 31),
end
)
self.stdout.write(f'{current}{chunk_end} を取得中...')
records = fetch_from_api(current.isoformat(), chunk_end.isoformat())
saved = upsert_records(records, stdout=None)
total_saved += saved
self.stdout.write(f' {saved} 件保存')
current = datetime.date(chunk_end.year + 1, 1, 1)
self.stdout.write(self.style.SUCCESS(f'完了: 合計 {total_saved} 件を保存しました。'))

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0 on 2026-02-28 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='WeatherRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(unique=True, verbose_name='日付')),
('temp_mean', models.FloatField(blank=True, null=True, verbose_name='平均気温(℃)')),
('temp_max', models.FloatField(blank=True, null=True, verbose_name='最高気温(℃)')),
('temp_min', models.FloatField(blank=True, null=True, verbose_name='最低気温(℃)')),
('sunshine_h', models.FloatField(blank=True, null=True, verbose_name='日照時間(h)')),
('precip_mm', models.FloatField(blank=True, null=True, verbose_name='降水量(mm)')),
('wind_max', models.FloatField(blank=True, null=True, verbose_name='最大風速(m/s)')),
('pressure_min', models.FloatField(blank=True, null=True, verbose_name='最低気圧(hPa)')),
],
options={
'verbose_name': '気象記録',
'verbose_name_plural': '気象記録',
'ordering': ['date'],
},
),
]

View File

@@ -0,0 +1,20 @@
from django.db import models
class WeatherRecord(models.Model):
date = models.DateField(unique=True, verbose_name="日付")
temp_mean = models.FloatField(null=True, blank=True, verbose_name="平均気温(℃)")
temp_max = models.FloatField(null=True, blank=True, verbose_name="最高気温(℃)")
temp_min = models.FloatField(null=True, blank=True, verbose_name="最低気温(℃)")
sunshine_h = models.FloatField(null=True, blank=True, verbose_name="日照時間(h)")
precip_mm = models.FloatField(null=True, blank=True, verbose_name="降水量(mm)")
wind_max = models.FloatField(null=True, blank=True, verbose_name="最大風速(m/s)")
pressure_min = models.FloatField(null=True, blank=True, verbose_name="最低気圧(hPa)")
class Meta:
verbose_name = "気象記録"
verbose_name_plural = "気象記録"
ordering = ['date']
def __str__(self):
return str(self.date)

View File

@@ -0,0 +1,16 @@
from rest_framework import serializers
from .models import WeatherRecord
class WeatherRecordSerializer(serializers.ModelSerializer):
class Meta:
model = WeatherRecord
fields = '__all__'
class WeatherSyncSerializer(serializers.ModelSerializer):
"""Windmill からの POST 用id不要"""
class Meta:
model = WeatherRecord
fields = ['date', 'temp_mean', 'temp_max', 'temp_min',
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']

View File

@@ -0,0 +1,13 @@
from django.urls import path
from . import views
urlpatterns = [
# Windmill 向けAPIキー認証
path('sync/', views.WeatherSyncView.as_view(), name='weather-sync'),
# フロントエンド向けJWT認証
path('records/', views.WeatherRecordListView.as_view(), name='weather-records'),
path('summary/', views.WeatherSummaryView.as_view(), name='weather-summary'),
path('gdd/', views.WeatherGDDView.as_view(), name='weather-gdd'),
path('similarity/', views.WeatherSimilarityView.as_view(), name='weather-similarity'),
]

View File

@@ -0,0 +1,349 @@
import secrets
import math
import datetime
from statistics import mean, stdev
from django.conf import settings
from django.db.models import Avg, Sum, Max, Min, Count, Q
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import BasePermission, IsAuthenticated
from .models import WeatherRecord
from .serializers import WeatherRecordSerializer, WeatherSyncSerializer
# ──────────────────────────────────────────────
# 認証クラス
# ──────────────────────────────────────────────
class WeatherAPIKeyPermission(BasePermission):
"""X-API-Key ヘッダーで認証Windmill向け"""
def has_permission(self, request, view):
key = request.headers.get('X-API-Key', '')
expected = getattr(settings, 'MAIL_API_KEY', '') # 既存キーを流用
if not key or not expected:
return False
return secrets.compare_digest(key, expected)
# ──────────────────────────────────────────────
# Windmill 向け: 日次データ同期 (POST)
# ──────────────────────────────────────────────
class WeatherSyncView(APIView):
"""
POST /api/weather/sync/
Windmill から気象データを受け取り upsert する。
単一オブジェクト、またはリスト形式どちらでも受け付ける。
"""
permission_classes = [WeatherAPIKeyPermission]
def post(self, request):
data = request.data
if isinstance(data, dict):
data = [data]
saved = 0
errors = []
for item in data:
date_val = item.get('date')
if not date_val:
errors.append({'item': item, 'error': 'date is required'})
continue
try:
record, _ = WeatherRecord.objects.get_or_create(date=date_val)
for field in ['temp_mean', 'temp_max', 'temp_min',
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']:
val = item.get(field)
if val is not None:
setattr(record, field, float(val))
record.save()
saved += 1
except Exception as e:
errors.append({'item': item, 'error': str(e)})
result = {'saved': saved}
if errors:
result['errors'] = errors
code = status.HTTP_201_CREATED if saved > 0 else status.HTTP_400_BAD_REQUEST
return Response(result, status=code)
# ──────────────────────────────────────────────
# フロントエンド向け: 日次レコード一覧 (GET)
# ──────────────────────────────────────────────
class WeatherRecordListView(APIView):
"""
GET /api/weather/records/?year=2025
GET /api/weather/records/?start=2025-01-01&end=2025-12-31
"""
permission_classes = [IsAuthenticated]
def get(self, request):
qs = WeatherRecord.objects.all()
year = request.query_params.get('year')
start = request.query_params.get('start')
end = request.query_params.get('end')
if year:
qs = qs.filter(date__year=int(year))
if start:
qs = qs.filter(date__gte=start)
if end:
qs = qs.filter(date__lte=end)
serializer = WeatherRecordSerializer(qs, many=True)
return Response(serializer.data)
# ──────────────────────────────────────────────
# フロントエンド向け: 月別サマリー (GET)
# ──────────────────────────────────────────────
class WeatherSummaryView(APIView):
"""
GET /api/weather/summary/?year=2025
月別集計(平均気温、降水量合計、日照合計、猛暑日・冬日数など)
"""
permission_classes = [IsAuthenticated]
def get(self, request):
year = request.query_params.get('year')
if not year:
return Response({'error': 'year parameter is required'}, status=status.HTTP_400_BAD_REQUEST)
qs = WeatherRecord.objects.filter(date__year=int(year))
monthly = []
for month in range(1, 13):
mqs = qs.filter(date__month=month)
agg = mqs.aggregate(
temp_mean_avg=Avg('temp_mean'),
temp_max_avg=Avg('temp_max'),
temp_min_avg=Avg('temp_min'),
precip_total=Sum('precip_mm'),
sunshine_total=Sum('sunshine_h'),
wind_max=Max('wind_max'),
)
hot_days = mqs.filter(temp_max__gte=35).count()
cold_days = mqs.filter(temp_min__lt=0).count()
rainy_days = mqs.filter(precip_mm__gte=1.0).count()
monthly.append({
'month': month,
**{k: (round(v, 1) if v is not None else None) for k, v in agg.items()},
'hot_days': hot_days,
'cold_days': cold_days,
'rainy_days': rainy_days,
})
# 年間サマリー
year_agg = qs.aggregate(
temp_mean_avg=Avg('temp_mean'),
precip_total=Sum('precip_mm'),
sunshine_total=Sum('sunshine_h'),
)
annual = {k: (round(v, 1) if v is not None else None) for k, v in year_agg.items()}
annual['hot_days'] = qs.filter(temp_max__gte=35).count()
annual['cold_days'] = qs.filter(temp_min__lt=0).count()
return Response({'year': int(year), 'monthly': monthly, 'annual': annual})
# ──────────────────────────────────────────────
# 積算温度 (GDD) (GET)
# ──────────────────────────────────────────────
class WeatherGDDView(APIView):
"""
GET /api/weather/gdd/?start_date=2025-05-01&base_temp=10&end_date=2025-09-30
起算日から指定日(省略時は昨日)までの有効積算温度を返す。
日積算温度 = max(0, 平均気温 - 基準温度)
"""
permission_classes = [IsAuthenticated]
def get(self, request):
start_date = request.query_params.get('start_date')
if not start_date:
return Response({'error': 'start_date is required'}, status=status.HTTP_400_BAD_REQUEST)
base_temp = float(request.query_params.get('base_temp', 0.0))
end_date = request.query_params.get('end_date') or (
datetime.date.today() - datetime.timedelta(days=1)
).isoformat()
records = WeatherRecord.objects.filter(
date__gte=start_date,
date__lte=end_date,
temp_mean__isnull=False,
).values('date', 'temp_mean')
cumulative = 0.0
daily_records = []
for r in records:
daily_gdd = max(0.0, r['temp_mean'] - base_temp)
cumulative += daily_gdd
daily_records.append({
'date': r['date'],
'temp_mean': r['temp_mean'],
'daily_gdd': round(daily_gdd, 2),
'cumulative_gdd': round(cumulative, 2),
})
return Response({
'start_date': start_date,
'end_date': end_date,
'base_temp': base_temp,
'total_gdd': round(cumulative, 2),
'records': daily_records,
})
# ──────────────────────────────────────────────
# 類似年分析 (GET)
# ──────────────────────────────────────────────
def _year_feature(records_qs, month_from, month_to, day_to):
"""
指定した月/日の範囲の特徴量ベクトルを返す。
month_from=1, month_to=2, day_to=28 なら 1/1〜2/28 のデータを使う。
返り値: (mean_temp, total_precip, total_sunshine) または None
"""
# 1/1 から month_to/day_to まで
qs = records_qs.filter(
Q(date__month__lt=month_to) |
Q(date__month=month_to, date__day__lte=day_to)
)
agg = qs.aggregate(
mean_temp=Avg('temp_mean'),
total_precip=Sum('precip_mm'),
total_sunshine=Sum('sunshine_h'),
)
if any(v is None for v in agg.values()):
return None
return (agg['mean_temp'], agg['total_precip'], agg['total_sunshine'])
class WeatherSimilarityView(APIView):
"""
GET /api/weather/similarity/?year=2026
今年の 1/1〜今日 の気象パターンと過去年を比較し、
最も似た上位3年とその年の残期間の実績データを返す。
開花・収穫予測の参考情報として使う。
"""
permission_classes = [IsAuthenticated]
def get(self, request):
target_year = int(request.query_params.get('year', datetime.date.today().year))
today = datetime.date.today()
# 比較基準日今年の場合は昨日まで、過去年は12/31まで
if target_year == today.year:
compare_end = today - datetime.timedelta(days=1)
else:
compare_end = datetime.date(target_year, 12, 31)
limit_month = compare_end.month
limit_day = compare_end.day
# 対象年の特徴量
target_qs = WeatherRecord.objects.filter(date__year=target_year)
target_feat = _year_feature(target_qs, 1, limit_month, limit_day)
if target_feat is None:
return Response({'error': f'{target_year} 年のデータが不足しています'}, status=status.HTTP_400_BAD_REQUEST)
# 全年リスト(対象年を除く)
all_years = (
WeatherRecord.objects
.exclude(date__year=target_year)
.values_list('date__year', flat=True)
.distinct()
.order_by('date__year')
)
year_features = {}
for yr in all_years:
qs = WeatherRecord.objects.filter(date__year=yr)
feat = _year_feature(qs, 1, limit_month, limit_day)
if feat is not None:
year_features[yr] = feat
if not year_features:
return Response({'error': '比較可能な過去データがありません'}, status=status.HTTP_404_NOT_FOUND)
# 正規化: 各特徴量の平均・標準偏差
all_feats = list(year_features.values()) + [target_feat]
n_features = 3
feat_means = [mean(f[i] for f in all_feats) for i in range(n_features)]
feat_stds = []
for i in range(n_features):
vals = [f[i] for f in all_feats]
sd = stdev(vals) if len(vals) > 1 else 1.0
feat_stds.append(sd if sd > 0 else 1.0)
def normalize(feat):
return tuple((feat[i] - feat_means[i]) / feat_stds[i] for i in range(n_features))
target_norm = normalize(target_feat)
distances = {}
for yr, feat in year_features.items():
norm = normalize(feat)
dist = math.sqrt(sum((target_norm[i] - norm[i]) ** 2 for i in range(n_features)))
distances[yr] = dist
top3 = sorted(distances.items(), key=lambda x: x[1])[:3]
# 類似年それぞれの月別統計を返す
result_years = []
for yr, dist in top3:
qs = WeatherRecord.objects.filter(date__year=yr)
monthly = []
for month in range(1, 13):
mqs = qs.filter(date__month=month)
agg = mqs.aggregate(
temp_mean_avg=Avg('temp_mean'),
temp_max_avg=Avg('temp_max'),
precip_total=Sum('precip_mm'),
sunshine_total=Sum('sunshine_h'),
)
hot_days = mqs.filter(temp_max__gte=35).count()
storm_days = mqs.filter(wind_max__gte=10, precip_mm__gte=30).count()
monthly.append({
'month': month,
**{k: (round(v, 1) if v is not None else None) for k, v in agg.items()},
'hot_days': hot_days,
'storm_days': storm_days,
})
feat = year_features[yr]
result_years.append({
'year': yr,
'distance': round(dist, 3),
'features': {
'mean_temp': round(feat[0], 2),
'total_precip': round(feat[1], 1),
'total_sunshine': round(feat[2], 1),
},
'monthly': monthly,
})
target_feat_dict = {
'mean_temp': round(target_feat[0], 2),
'total_precip': round(target_feat[1], 1),
'total_sunshine': round(target_feat[2], 1),
}
return Response({
'target_year': target_year,
'comparison_period': f'1/1〜{limit_month}/{limit_day}',
'target_features': target_feat_dict,
'similar_years': result_years,
})

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
'apps.plans',
'apps.reports',
'apps.mail',
'apps.weather',
]
MIDDLEWARE = [

View File

@@ -56,4 +56,5 @@ urlpatterns = [
path('api/auth/jwt/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
path('api/mail/', include('apps.mail.urls')),
path('api/weather/', include('apps.weather.urls')),
]

View File

@@ -9,3 +9,4 @@ odfpy==1.4
WeasyPrint>=60.1
gunicorn>=21.0,<22.0
setuptools<75
requests>=2.31

View File

@@ -31,6 +31,7 @@ services:
DB_PORT: 5432
SECRET_KEY: ${SECRET_KEY}
DEBUG: "True"
MAIL_API_KEY: ${MAIL_API_KEY}
ports:
- "8000:8000"
volumes:

View File

@@ -0,0 +1,599 @@
# マスタードキュメント - メール通知関連編
> **最終更新**: 2026-02-25
> **対象バージョン**: Phase 1 完了時点(本番稼働中)
> **目的**: このドキュメントだけでメール通知機能の全容を把握できること
---
## 目次
1. [システム概要・全体構成](#1-システム概要全体構成)
2. [データモデル](#2-データモデル)
3. [API仕様バックエンド](#3-api仕様バックエンド)
4. [Windmill フロー仕様](#4-windmill-フロー仕様)
5. [画面仕様(フロントエンド)](#5-画面仕様フロントエンド)
6. [本番環境の設定値](#6-本番環境の設定値)
7. [設計判断と制約](#7-設計判断と制約)
8. [運用手順](#8-運用手順)
9. [ソースファイル索引](#9-ソースファイル索引)
---
## 1. システム概要・全体構成
### 機能の目的
複数のメールアカウントGmail × 2 + Xserver × 6 = 計8アカウントに届く大量のメールを自動でフィルタリングし、農家にとって重要なメールだけを LINE で通知する。
### システム構成図
```
[メールサーバー群] [Windmill] [KeinaSystem]
Gmail (2口) ─IMAP─▶ mail_filter フロー ──API──▶ Django バックエンド
Xserver (6口) ↓ (DB記録・ルール参照)
[Gemini API] ↓
LLM判定 フロントエンド
↓ ・メール履歴画面
LINE Messaging API ・ルール管理画面
(重要と判定した場合のみ通知) ・フィードバックページ
LINE通知文にフィードバックURL ────────┘
を含め、ユーザーがタップして
フィードバックを送信
```
### 処理フロー1メールあたり
```
1. IMAP 接続 → 前回処理済み UID 以降の新着メールを取得
2. 送信者ルール確認GET /api/mail/sender-rule/
├── never_notify → スキップ(記録しない)
├── always_notify → LLMスキップ、即 LINE 通知
└── ルールなし → 3へ
3. 過去フィードバック集計取得GET /api/mail/sender-context/
4. Gemini API で重要度判定LLM
5. KeinaSystem に記録POST /api/mail/emails/
├── not_important → 記録のみ、通知なし
└── important → フィードバックURLを発行、LINE 通知
6. 処理済み最終 UID を Windmill Variable に保存
```
### 10分ごとの定期実行
Windmill スケジュール `0 */10 * * * *` で自動実行。サーバー上の production Windmill で稼働。
---
## 2. データモデル
### 2.1 MailSender送信者ルール
**テーブル名**: `mail_mailsender`
| フィールド | 型 | 説明 |
|---|---|---|
| `id` | BigAutoField | PK |
| `email` | EmailField (null可) | アドレス指定ルールの場合 |
| `domain` | CharField(255, null可) | ドメイン指定ルールの場合 |
| `rule` | CharField(20) | `never_notify` / `always_notify` |
| `note` | TextField | メモ(任意)|
| `created_at` | DateTimeField | 作成日時 |
| `updated_at` | DateTimeField | 更新日時 |
**制約**: `email``domain` は必ずどちらか一方のみ設定DB CHECK 制約 `mail_sender_email_or_domain`
**ルール判定の優先順位**: アドレスルールが先、次にドメインルール
### 2.2 MailEmail受信メール記録
**テーブル名**: `mail_mailemail`
| フィールド | 型 | 説明 |
|---|---|---|
| `id` | BigAutoField | PK |
| `account` | CharField(20) | `gmail` / `gmail_service` / `xserver` / `hotmail` |
| `message_id` | CharField(500, unique) | メールの Message-ID ヘッダー(重複防止に使用)|
| `sender_email` | EmailField | 送信者メールアドレス |
| `sender_domain` | CharField(255) | 送信者ドメイン |
| `subject` | CharField(500) | 件名 |
| `body_preview` | TextField | 本文冒頭最大500文字|
| `received_at` | DateTimeField | 受信日時 |
| `llm_verdict` | CharField(20) | `important` / `not_important` |
| `notified_at` | DateTimeField (null可) | LINE 通知日時(通知済みの場合のみ)|
| `feedback` | CharField(20, null可) | `important` / `not_important` / `never_notify` / `always_notify` |
| `feedback_at` | DateTimeField (null可) | フィードバック日時 |
**ordering**: `-received_at`(新しい順)
**重複防止**: `message_id` の unique 制約。同じメールが複数アカウントで受信された場合は 2件目以降を「重複メール、スキップ」として処理400エラーを無視
### 2.3 MailNotificationTokenフィードバック用トークン
**テーブル名**: `mail_mailnotificationtoken`
| フィールド | 型 | 説明 |
|---|---|---|
| `id` | BigAutoField | PK |
| `email` | OneToOneField → MailEmail | |
| `token` | UUIDField (unique) | フィードバック URL 用 UUID |
| `created_at` | DateTimeField | |
**用途**: `important` と判定されたメールに対して作成。`/mail/feedback/<token>/` の URL をLINE通知文に含める。有効期限なし。
---
## 3. API仕様バックエンド
ベース URL: `https://main.keinafarm.net/api/mail/`
### 3.1 認証方式
| 認証方式 | 対象エンドポイント | ヘッダー |
|---|---|---|
| APIキー認証Windmill用 | sender-rule, sender-context, POST emails/ | `X-API-Key: <MAIL_API_KEY>` |
| JWT認証フロントエンド用 | GET emails/, stats/, senders/, PATCH emails/<pk>/feedback/ | `Authorization: Bearer <token>` |
| 認証不要 | GET/POST feedback/<token>/ | なし |
**MAIL_API_KEY**: `.env.production``MAIL_API_KEY` と一致している必要がある。Windmill Variable `u/admin/KEINASYSTEM_API_KEY` に設定。
### 3.2 Windmill向けエンドポイント
#### GET /api/mail/sender-rule/
送信者ルールを確認する。
**リクエスト**: クエリパラメータ `email` `domain`
**レスポンス例**:
```json
{"matched": true, "rule": "never_notify", "match_type": "address"}
{"matched": false}
```
**判定順序**: アドレス一致 → ドメイン一致 → マッチなし
---
#### GET /api/mail/sender-context/
過去フィードバックの集計を返すLLMへのコンテキスト用
**リクエスト**: クエリパラメータ `email` `domain`
**レスポンス例**:
```json
{
"total_notified": 8,
"important": 2,
"not_important": 5,
"never_notify": 0,
"no_feedback": 1
}
```
---
#### POST /api/mail/emails/
メールを記録し、`important` の場合はフィードバックURLを発行する。
**リクエストボディ**:
```json
{
"account": "gmail",
"message_id": "<unique-message-id>",
"sender_email": "sender@example.com",
"sender_domain": "example.com",
"subject": "件名",
"body_preview": "本文冒頭...",
"received_at": "2026-02-25T15:46:00+09:00",
"llm_verdict": "important"
}
```
**レスポンス例**:
```json
{"id": 69, "feedback_url": "https://main.keinafarm.net/mail/feedback/<uuid>"}
```
`not_important` の場合: `{"id": 68}`feedback_url なし)
**重複処理**: `message_id` が既存の場合 400 を返す。Windmill 側で「重複メール、スキップ」として処理。
---
### 3.3 フィードバックエンドポイント(認証不要)
#### GET /api/mail/feedback/\<token\>/
フィードバックページ表示用にメール情報を返す。
**レスポンス例**:
```json
{
"id": 69,
"sender_email": "sender@example.com",
"sender_domain": "example.com",
"subject": "件名",
"body_preview": "本文...",
"received_at": "2026-02-25T15:46:00+09:00",
"feedback": null
}
```
---
#### POST /api/mail/feedback/\<token\>/
フィードバックを保存する。
**リクエストボディ**:
```json
{
"feedback": "never_notify",
"scope": "address"
}
```
`feedback``important` / `not_important` / `never_notify` / `always_notify` のいずれか。
`scope``never_notify` / `always_notify` の場合のみ必要(`address` / `domain`)。
`never_notify` / `always_notify` + scope の場合、`MailSender` レコードを自動 upsert。
---
### 3.4 フロントエンド向けエンドポイントJWT認証
#### GET /api/mail/emails/
メール処理履歴を返す最新100件
**クエリパラメータ**: `account`(アカウント絞り込み)、`verdict`LLM判定絞り込み
---
#### PATCH /api/mail/emails/\<pk\>/feedback/
履歴画面から直接フィードバックを更新する。
**リクエストボディ**: `feedback`(必須)、`scope``never_notify`/`always_notify` 時のみ)
---
#### GET /api/mail/stats/
ダッシュボード用統計。
**レスポンス例**:
```json
{
"today_processed": 12,
"today_notified": 3,
"feedback_pending": 1,
"total_rules": 5
}
```
---
#### GET /api/mail/senders/
送信者ルール一覧。
#### POST /api/mail/senders/
送信者ルール追加。`email` または `domain` のどちらか一方を指定。
#### DELETE /api/mail/senders/\<id\>/
送信者ルール削除。
---
## 4. Windmill フロー仕様
### 4.1 基本情報
| 項目 | 値 |
|---|---|
| フローパス | `f/mail/mail_filter` |
| スクリプト言語 | Python 3 |
| スケジュール | `0 */10 * * * *`10分ごと|
| スケジュールパス | `f/mail/mail_filter_schedule` |
| Windmill URL | `https://windmill.keinafarm.net` |
| ワークスペース | `admins` |
### 4.2 処理対象アカウント
| 変数名 | メールアドレス | サーバー |
|---|---|---|
| `XSERVER1` | `akira@keinafarm.com` | `sv579.xserver.jp:993` |
| `XSERVER2` | `service@keinafarm.com` | `sv579.xserver.jp:993` |
| `XSERVER3` | `midori@keinafarm.com` | `sv579.xserver.jp:993` |
| `XSERVER4` | `kouseiren@keinafarm.com` | `sv579.xserver.jp:993` |
| `XSERVER5` | `post@keinafarm.com` | `sv579.xserver.jp:993` |
| `XSERVER6` | `sales@keinafarm.com` | `sv579.xserver.jp:993` |
| `GMAIL` | `akiracraftwork@gmail.com` | `imap.gmail.com:993`、All Mail |
| `GMAIL2` | `akiranoushi@gmail.com` | `imap.gmail.com:993`、All Mail |
Hotmail は定義済みだがコメントアウト(未有効化)。
### 4.3 Windmill Variables 一覧
本番 Windmill (`windmill.keinafarm.net`、ワークスペース `admins`) に設定。
| Variable パス | 内容 | Secret |
|---|---|---|
| `u/admin/GMAIL_IMAP_USER` | Gmail ユーザー | ✓ |
| `u/admin/GMAIL_IMAP_PASSWORD` | Gmail アプリパスワード | ✓ |
| `u/admin/GMAIL2_IMAP_USER` | Gmail2 ユーザー | ✓ |
| `u/admin/GMAIL2_IMAP_PASSWORD` | Gmail2 アプリパスワード | ✓ |
| `u/admin/XSERVER1_IMAP_USER` | `akira@keinafarm.com` | — |
| `u/admin/XSERVER1_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/XSERVER2_IMAP_USER` | `service@keinafarm.com` | — |
| `u/admin/XSERVER2_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/XSERVER3_IMAP_USER` | `midori@keinafarm.com` | — |
| `u/admin/XSERVER3_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/XSERVER4_IMAP_USER` | `kouseiren@keinafarm.com` | — |
| `u/admin/XSERVER4_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/XSERVER5_IMAP_USER` | `post@keinafarm.com` | — |
| `u/admin/XSERVER5_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/XSERVER6_IMAP_USER` | `sales@keinafarm.com` | — |
| `u/admin/XSERVER6_IMAP_PASSWORD` | Xserver IMAP パスワード | ✓ |
| `u/admin/GEMINI_API_KEY` | Gemini API キー | ✓ |
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging API トークン | ✓ |
| `u/admin/LINE_TO` | LINE 通知先ユーザー ID | ✓ |
| `u/admin/KEINASYSTEM_API_KEY` | KeinaSystem API キー(`.env.production``MAIL_API_KEY` と同値)| ✓ |
| `u/admin/KEINASYSTEM_API_URL` | `https://main.keinafarm.net` | — |
| `u/admin/MAIL_FILTER_GMAIL_LAST_UID` | Gmail 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_GMAIL2_LAST_UID` | Gmail2 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER1_LAST_UID` | Xserver1 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER2_LAST_UID` | Xserver2 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER3_LAST_UID` | Xserver3 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER4_LAST_UID` | Xserver4 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER5_LAST_UID` | Xserver5 最終処理済み UID | — |
| `u/admin/MAIL_FILTER_XSERVER6_LAST_UID` | Xserver6 最終処理済み UID | — |
### 4.4 LAST_UID の仕組み
- **初回実行**: `LAST_UID``0` または未設定の場合、現在の最大 UID を記録して終了(既存メールを遡らない)
- **通常実行**: `LAST_UID + 1` 以降の UID を検索して処理
- **エラー時**: 個別メッセージの処理に失敗しても、成功した最大 UID まで更新する
### 4.5 LLM 判定Gemini
モデル: `gemini-2.0-flash``temperature=0`, `maxOutputTokens=10`
プロンプトに渡す情報:
- 送信者アドレス・件名・本文冒頭
- 過去の同一送信者のフィードバック集計(`sender-context` API から取得)
回答: `1`(重要)/ `2`重要でないの1文字。`1` で始まる場合 `important`
### 4.6 LINE 通知文フォーマット
```
📧 重要なメールが届きました
宛先: Gmail (メイン)
差出人: sender@example.com
件名: 件名テキスト
フィードバック:
https://main.keinafarm.net/mail/feedback/<uuid>
```
---
## 5. 画面仕様(フロントエンド)
### 5.1 フィードバックページ(認証不要)
**URL**: `/mail/feedback/[token]`
**ファイル**: `frontend/src/app/mail/feedback/[token]/page.tsx`
LINE 通知のリンクから直接アクセス。JWT 認証不要のため、`api` インスタンスJWT自動付与ではなく素の `fetch` を使用。
**表示内容**:
- 送信者アドレス・件名・受信日時・本文冒頭
- フィードバックボタン: 重要だった / 普通のメール / 今後通知しない / 常に通知してほしい
- `今後通知しない` / `常に通知` 選択時: アドレス / ドメイン の適用範囲を選択
**状態**:
- 既にフィードバック済みの場合は現在の値をハイライト表示(再選択可能)
- 送信完了後「受け付けました」画面に切り替わる
---
### 5.2 メール処理履歴JWT認証
**URL**: `/mail/history`
**Navbar**: 「メール履歴」History アイコン)
**ファイル**: `frontend/src/app/mail/history/page.tsx`
**表示内容**: 処理したメール最新100件
| カラム | 内容 |
|---|---|
| 受信日時 | 日時 + アカウント名 |
| 送信者 | メールアドレス |
| 件名 | テキストtruncate|
| LLM判定 | 重要(赤)/ 通常(灰)バッジ |
| フィードバック | 状態バッジ + 編集ボタン |
**フィルター**: アカウント別・LLM判定別セレクトボックス
**フィードバックモーダル**: 履歴画面からも4択でフィードバックを設定・変更できる。`never_notify` / `always_notify` 選択時はアドレス/ドメインの適用範囲を表示。
---
### 5.3 メール通知ルール管理JWT認証
**URL**: `/mail/rules`
**Navbar**: 「メールルール」Shield アイコン)
**ファイル**: `frontend/src/app/mail/rules/page.tsx`
**追加フォーム**:
- 種別(アドレス / ドメイン)
- ルール(通知しない / 常に通知)
- 値(メールアドレスまたはドメイン名)
- メモ(任意)
**一覧表示**: 種別バッジ・ルールバッジ・値・メモ・設定日・削除ボタン
**ルールが自動追加される場面**: フィードバックで `never_notify` / `always_notify` を選択してスコープを指定すると自動登録される。
---
## 6. 本番環境の設定値
### バックエンド(`.env.production`
```
MAIL_API_KEY=<Windmill の KEINASYSTEM_API_KEY と同じ値>
```
`settings.py` での参照:
```python
MAIL_API_KEY = os.environ.get('MAIL_API_KEY', '')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
```
`FRONTEND_URL` はフィードバック URL の生成に使用(本番値: `https://main.keinafarm.net`)。
### デプロイコマンド
```bash
# サーバー上で実行(--env-file を必ず指定)
cd /home/keinasystem/keinasystem_t02
git pull
docker compose -f docker-compose.prod.yml --env-file .env.production build
docker compose -f docker-compose.prod.yml --env-file .env.production up -d
```
**注意**: `--env-file .env.production` を省略すると SECRET_KEY 等が空になりバックエンドが起動しない。
### Windmill フローの更新手順
ローカルの `flows/mail_filter.flow.json` を編集後:
```bash
cd C:/Users/akira/Develop/windmill_workflow
# サーバーに転送
scp flows/mail_filter.flow.json keinafarm-claude:/tmp/mail_filter.flow.json
# サーバー上でデプロイWindmill API 経由)
ssh keinafarm-claude 'TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh; WM=http://172.18.0.15:8000/api/w/admins
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$WM/flows/delete/f/mail/mail_filter"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @/tmp/mail_filter.flow.json "$WM/flows/create"'
```
---
## 7. 設計判断と制約
### Windmill と KeinaSystem の疎結合
Windmill はスケジューラ・LLM呼び出し担当、KeinaSystem は DB・UI 担当として明確に分離。両者は REST APIAPIキー認証でのみ連携。
→ Windmill の障害が KeinaSystem のメイン機能(作付け計画等)に影響しない。
### APIキー認証の実装
`MailAPIKeyPermission``BasePermission` サブクラス)で `X-API-Key` ヘッダーを検証。`secrets.compare_digest` でタイミング攻撃を防止。
`authentication_classes = []` を明示的に設定したビューとしていないビューがある:
- `SenderRuleView`, `SenderContextView`: `authentication_classes = []` を設定済み → 403
- `MailEmailView` の POST: 設定なし(デフォルトの JWTAuthentication が動く)→ キー不一致時は 401
**統一するなら** `MailEmailView` にも `authentication_classes = []` を追加すべき(現状は動作上問題なし)。
### フィードバック URL のセキュリティ
UUID v4 のランダムトークンのみで認証。有効期限なし。LINE に送信された URL を知っている人なら誰でもフィードバックを送れる(悪用リスクは低いため許容)。
### 重複メール処理
同じメールが複数アカウントで受信される場合(転送設定等)、`message_id` の unique 制約で2件目以降を自動スキップ。最初に処理したアカウントの `account_code` でDBに記録される。
---
## 8. 運用手順
### 新しいメールアカウントを追加する場合
1. `flows/mail_filter.flow.json``ACCOUNTS` リストにエントリを追加
2. 本番 Windmill に Variables を追加USER, PASSWORD, LAST_UID
3. フローを再デプロイ
```bash
# Variable 追加例(サーバー上で実行)
ssh keinafarm-claude 'TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh; WM=http://172.18.0.15:8000/api/w/admins
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"path\":\"u/admin/XSERVER7_IMAP_USER\",\"value\":\"newuser@keinafarm.com\",\"is_secret\":false,\"description\":\"\"}" \
"$WM/variables/create"'
```
### LINE トークンが期限切れになった場合
LINE Developers Console でトークンを再発行し、本番 Windmill の Variable を更新:
```bash
ssh keinafarm-claude 'TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh; WM=http://172.18.0.15:8000/api/w/admins
# 古いものを削除してから再作成
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$WM/variables/delete/u/admin/LINE_CHANNEL_ACCESS_TOKEN"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"path\":\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\",\"value\":\"<新トークン>\",\"is_secret\":true,\"description\":\"LINE Messaging APIトークン\"}" \
"$WM/variables/create"'
```
### フローのログを確認する
```bash
# 最近のジョブ一覧
ssh keinafarm-claude 'TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh
curl -s -H "Authorization: Bearer $TOKEN" "http://172.18.0.15:8000/api/w/admins/jobs/completed/list?per_page=10" \
| grep -o "\"id\":\"[^\"]*\"\|\"started_at\":\"[^\"]*\"\|\"script_path\":\"[^\"]*\"" \
| paste - - - | grep mail_filter'
# ステップジョブのログ取得
ssh keinafarm-claude 'TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh; JOB_ID=<ステップジョブID>
curl -s -H "Authorization: Bearer $TOKEN" \
"http://172.18.0.15:8000/api/w/admins/jobs/completed/get/$JOB_ID" \
| grep -o "\"logs\":\"[^\"]*\"" | sed "s/\\\\n/\n/g"'
```
---
## 9. ソースファイル索引
### バックエンド
| ファイル | 内容 |
|---|---|
| `backend/apps/mail/models.py` | MailSender, MailEmail, MailNotificationToken |
| `backend/apps/mail/serializers.py` | MailSenderSerializer, MailEmailCreateSerializer, MailEmailListSerializer, FeedbackDetailSerializer |
| `backend/apps/mail/views.py` | 全ビューSenderRuleView, SenderContextView, MailEmailView, MailStatsView, FeedbackView, MailEmailFeedbackView, MailSenderViewSet+ MailAPIKeyPermission |
| `backend/apps/mail/urls.py` | URL ルーティング |
| `backend/apps/mail/admin.py` | Django 管理画面登録 |
| `backend/apps/mail/migrations/` | マイグレーション |
| `backend/keinasystem/settings.py` | `MAIL_API_KEY`, `FRONTEND_URL` 設定L161-162|
| `backend/keinasystem/urls.py` | `path('api/mail/', include('apps.mail.urls'))` |
### フロントエンド
| ファイル | 内容 |
|---|---|
| `frontend/src/app/mail/feedback/[token]/page.tsx` | フィードバックページ(認証不要)|
| `frontend/src/app/mail/history/page.tsx` | メール処理履歴画面 |
| `frontend/src/app/mail/rules/page.tsx` | 送信者ルール管理画面 |
| `frontend/src/components/Navbar.tsx` | 「メール履歴」「メールルール」リンク追加済み |
### Windmill フロー
| ファイル | 内容 |
|---|---|
| `C:/Users/akira/Develop/windmill_workflow/flows/mail_filter.flow.json` | フロー定義Python スクリプト本体を含む)|
本番 Windmill でのパス: `f/mail/mail_filter`
スケジュール: `f/mail/mail_filter_schedule`